Skip to main content

earl_protocol_http/
builder.rs

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
15/// Build a complete `PreparedHttpData` from an HTTP operation template.
16///
17/// Auth is **not** applied here — the caller applies it afterward.
18pub 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
47/// Build a complete `PreparedHttpData` from a GraphQL operation template.
48///
49/// Auth is **not** applied here — the caller applies it afterward.
50pub 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
102// ── Private helpers ──────────────────────────────────────────
103
104type 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}