diff --git a/Cargo.lock b/Cargo.lock index c258d5ea..c5a6152d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,6 +154,12 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" +[[package]] +name = "base64" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51" + [[package]] name = "bitflags" version = "1.3.2" @@ -1427,7 +1433,7 @@ version = "0.11.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b1ae8d9ac08420c66222fb9096fc5de435c3c48542bc5336c51892cffafb41" dependencies = [ - "base64", + "base64 0.21.5", "bytes", "encoding_rs", "futures-core", @@ -1596,7 +1602,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", + "base64 0.21.5", ] [[package]] @@ -1821,6 +1827,7 @@ version = "0.13.1" dependencies = [ "anyhow", "async-trait", + "base64 0.22.0", "bytes", "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 5f278e6d..b6f00e01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ rust-version = "1.74.0" [dependencies] anyhow = {version = "^1.0.75", features = ["backtrace"]} async-trait = "^0.1.73" +base64 = "0.22.0" bytes = { version = "1.5.0", features = ["serde"] } chrono = {version = "^0.4.31", default-features = false, features = ["clock", "serde", "std"]} clap = {version = "^4.4.2", features = ["derive"]} diff --git a/src/factory.rs b/src/factory.rs index 651e4dd9..e60bbb85 100644 --- a/src/factory.rs +++ b/src/factory.rs @@ -24,7 +24,6 @@ factori!(Request, { method = Method::GET, url = "http://localhost/url".parse().unwrap(), headers = HeaderMap::new(), - authentication = None, body = None, } }); diff --git a/src/http.rs b/src/http.rs index 08f7e267..346028cc 100644 --- a/src/http.rs +++ b/src/http.rs @@ -49,12 +49,13 @@ use crate::{ util::ResultExt, }; use anyhow::Context; +use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine}; use bytes::Bytes; use chrono::Utc; use futures::future; use indexmap::IndexMap; use reqwest::{ - header::{HeaderMap, HeaderName, HeaderValue}, + header::{self, HeaderMap, HeaderName, HeaderValue}, Client, }; use std::collections::HashSet; @@ -209,17 +210,6 @@ impl HttpEngine { .request(request.method.clone(), request.url.clone()) .headers(request.headers.clone()); - // Add auth - request_builder = match &request.authentication { - Some(Authentication::Basic { username, password }) => { - request_builder.basic_auth(username, password.as_ref()) - } - Some(Authentication::Bearer(token)) => { - request_builder.bearer_auth(token) - } - None => request_builder, - }; - // Add body if let Some(body) = &request.body { request_builder = request_builder.body(body.clone()); @@ -323,19 +313,12 @@ impl RequestBuilder { let method = self.recipe.method.parse()?; // Render everything in parallel - let (mut url, query, headers, authentication, body) = try_join!( + let (url, headers, body) = try_join!( self.render_url(), - self.render_query(), self.render_headers(), - self.render_authentication(), self.render_body(), )?; - // Join query into URL. if check prevents bare ? for empty query - if !query.is_empty() { - url.query_pairs_mut().extend_pairs(&query); - } - info!( recipe_id = %self.recipe.id, "Built request from recipe", @@ -351,20 +334,32 @@ impl RequestBuilder { method, url, headers, - authentication, body, }) } - /// Render a base URL, *without* query params + /// Render URL, including query params async fn render_url(&self) -> anyhow::Result { // Shitty try block - async { - let url = self.recipe.url.render(&self.template_context).await?; - url.parse().map_err(anyhow::Error::from) + let (mut url, query) = try_join!( + async { + let url = self + .recipe + .url + .render(&self.template_context) + .await + .context("Error rendering URL")?; + url.parse::().context("Invalid URL") + }, + self.render_query() + )?; + + // Join query into URL. if check prevents bare ? for empty query + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(&query); } - .await - .context("Error rendering URL") + + Ok(url) } /// Render query key=value params @@ -392,8 +387,10 @@ impl RequestBuilder { .collect::>()) } + /// Render all headers. This will also render authentication and merge it + /// into the headers async fn render_headers(&self) -> anyhow::Result { - let template_context = &self.template_context; + // Render base headers let iter = self .recipe .headers @@ -402,61 +399,83 @@ impl RequestBuilder { .filter(|(header, _)| { !self.options.disabled_headers.contains(*header) }) - .map(|(header, value_template)| async move { - let value = value_template - .render(template_context) - .await - .context(format!("Error rendering header `{header}`"))?; - // Strip leading/trailing line breaks because they're going to - // trigger a validation error and are probably a mistake. This - // is a balance between convenience and - // explicitness - let value = value.trim_matches(|c| c == '\n' || c == '\r'); - // String -> header conversions are fallible, if headers - // are invalid - Ok::<(HeaderName, HeaderValue), anyhow::Error>(( - header.try_into().context(format!( - "Error parsing header name `{header}`" - ))?, - value.try_into().context(format!( - "Error parsing value for header `{header}`" - ))?, - )) + .map(move |(header, value_template)| { + self.render_header(header, value_template) }); - Ok(future::try_join_all(iter) + let mut headers = future::try_join_all(iter) .await? .into_iter() - .collect::()) - } + .collect::(); - async fn render_authentication( - &self, - ) -> anyhow::Result> { + // Render auth method and modify headers accordingly let context = &self.template_context; - if let Some(authentication) = &self.recipe.authentication { - let output = match authentication { - collection::Authentication::Basic { username, password } => { - let username = username - .render(context) - .await - .context("Error rendering username")?; - let password = Template::render_opt(password, context) - .await - .context("Error rendering password")?; - record::Authentication::Basic { username, password } - } - collection::Authentication::Bearer(token) => { - let token = token - .render(context) - .await - .context("Error rendering bearer token")?; - record::Authentication::Bearer(token) - } - }; - Ok(Some(output)) - } else { - Ok(None) + match &self.recipe.authentication { + Some(collection::Authentication::Basic { username, password }) => { + // Encode as `username:password | base64` + // https://swagger.io/docs/specification/authentication/basic-authentication/ + let username = username + .render(context) + .await + .context("Error rendering username")?; + let password = Template::render_opt(password, context) + .await + .context("Error rendering password")? + .unwrap_or_default(); + let credentials = BASE64_STANDARD_NO_PAD + .encode(format!("{username}:{password}")); + headers.insert( + header::AUTHORIZATION, + // Error should be impossible since we know it's base64 + credentials.try_into().context( + "Error encoding basic authentication credentials", + )?, + ); + } + + Some(collection::Authentication::Bearer(token)) => { + let token = token + .render(context) + .await + .context("Error rendering bearer token")?; + headers.insert( + header::AUTHORIZATION, + format!("Bearer {token}") + .try_into() + .context("Error encoding bearer token")?, + ); + } + + None => {} } + + Ok(headers) + } + + /// Render a single key/value header + async fn render_header( + &self, + header: &str, + value_template: &Template, + ) -> anyhow::Result<(HeaderName, HeaderValue)> { + let value = value_template + .render(&self.template_context) + .await + .context(format!("Error rendering header `{header}`"))?; + // Strip leading/trailing line breaks because they're going to + // trigger a validation error and are probably a mistake. This + // is a balance between convenience and + // explicitness + let value = value.trim_matches(|c| c == '\n' || c == '\r'); + // String -> header conversions are fallible, if headers + // are invalid + Ok::<(HeaderName, HeaderValue), anyhow::Error>(( + header + .try_into() + .context(format!("Error encoding header name `{header}`"))?, + value.try_into().context(format!( + "Error encoding value for header `{header}`" + ))?, + )) } async fn render_body(&self) -> anyhow::Result> { diff --git a/src/http/record.rs b/src/http/record.rs index 13abda6f..35575d52 100644 --- a/src/http/record.rs +++ b/src/http/record.rs @@ -106,22 +106,10 @@ pub struct Request { pub url: Url, #[serde(with = "serde_header_map")] pub headers: HeaderMap, - #[serde(default)] - pub authentication: Option, /// Body content as bytes. This should be decoded as needed pub body: Option, } -/// A copy of [collection::Authentication], with templates rendered to strings -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum Authentication { - Basic { - username: String, - password: Option, - }, - Bearer(String), -} - /// A resolved HTTP response, with all content loaded and ready to be displayed /// to the user. A simpler alternative to [reqwest::Response], because there's /// no way to access all resolved data on that type at once. Resolving the