pub mod auth;
pub(crate) mod headers;
pub mod multipart;
pub mod resolve;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::assertions::Assertions;
use crate::capture::Captures;
use crate::error::SendraError;
use crate::request::auth::Auth;
use crate::request::headers::{deserialize_headers, serialize_headers};
use crate::request::multipart::MultipartPart;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "UPPERCASE")]
pub enum Method {
Get,
Post,
Put,
Patch,
Delete,
Head,
Options,
}
impl Method {
pub fn as_str(self) -> &'static str {
match self {
Method::Get => "GET",
Method::Post => "POST",
Method::Put => "PUT",
Method::Patch => "PATCH",
Method::Delete => "DELETE",
Method::Head => "HEAD",
Method::Options => "OPTIONS",
}
}
}
impl From<Method> for reqwest::Method {
fn from(m: Method) -> Self {
match m {
Method::Get => reqwest::Method::GET,
Method::Post => reqwest::Method::POST,
Method::Put => reqwest::Method::PUT,
Method::Patch => reqwest::Method::PATCH,
Method::Delete => reqwest::Method::DELETE,
Method::Head => reqwest::Method::HEAD,
Method::Options => reqwest::Method::OPTIONS,
}
}
}
impl std::fmt::Display for Method {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct Request {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub method: Method,
pub url: String,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
deserialize_with = "deserialize_headers",
serialize_with = "serialize_headers"
)]
#[cfg_attr(
feature = "schema",
schemars(schema_with = "headers::header_map_schema")
)]
pub headers: Vec<(String, String)>,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
deserialize_with = "deserialize_headers",
serialize_with = "serialize_headers"
)]
#[cfg_attr(
feature = "schema",
schemars(schema_with = "headers::header_map_schema")
)]
pub query: Vec<(String, String)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub json: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body_file: Option<String>,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
deserialize_with = "deserialize_headers",
serialize_with = "serialize_headers"
)]
#[cfg_attr(
feature = "schema",
schemars(schema_with = "headers::header_map_schema")
)]
pub form: Vec<(String, String)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub multipart: Vec<MultipartPart>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<Auth>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assertions: Option<Assertions>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pre_request: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_request: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capture: Option<Captures>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry: Option<RetryConfig>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct RetryConfig {
pub count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delay_ms: Option<u64>,
}
impl Request {
pub fn from_yaml_str(yaml: &str) -> Result<Self, SendraError> {
let request: Request = serde_yaml::from_str(yaml).map_err(SendraError::ParseStr)?;
request.validate()?;
Ok(request)
}
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SendraError> {
let path = path.as_ref();
let raw = std::fs::read_to_string(path).map_err(|source| SendraError::Io {
path: path.to_path_buf(),
source,
})?;
let request: Request = serde_yaml::from_str(&raw).map_err(|source| SendraError::Parse {
path: path.to_path_buf(),
source,
})?;
request.validate()?;
Ok(request)
}
pub(crate) fn validate(&self) -> Result<(), SendraError> {
let invalid = |reason: String| Err(SendraError::InvalidRequest { reason });
let mut set = Vec::new();
if self.body.is_some() {
set.push("body");
}
if self.json.is_some() {
set.push("json");
}
if self.body_file.is_some() {
set.push("body_file");
}
if !self.form.is_empty() {
set.push("form");
}
if !self.multipart.is_empty() {
set.push("multipart");
}
if set.len() > 1 {
return invalid(format!(
"at most one of `body`, `json`, `body_file`, `form`, `multipart` may be set, but found: {}",
set.join(", ")
));
}
for part in &self.multipart {
match (&part.value, &part.path) {
(Some(_), Some(_)) => {
return invalid(format!(
"multipart part `{}` has both `value` and `path`; exactly one is required",
part.name
));
}
(None, None) => {
return invalid(format!(
"multipart part `{}` has neither `value` nor `path`; exactly one is required",
part.name
));
}
_ => {}
}
}
if let Some(auth) = &self.auth {
if let Err(reason) = auth.validate_exclusivity() {
return invalid(reason);
}
if let Some(oauth) = &auth.oauth {
if let Err(reason) = oauth.validate_grant_fields() {
return invalid(reason);
}
}
if let Some(reason) = auth.collision_reason(&self.headers, &self.query) {
return invalid(reason);
}
}
Ok(())
}
pub fn label(&self) -> String {
match &self.name {
Some(name) => name.clone(),
None => format!("{} {}", self.method, self.url),
}
}
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(existing, _)| existing == name)
.map(|(_, value)| value.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_valid_request() {
let yaml = "\
name: Get user
method: GET
url: https://api.example.com/users/1
headers:
Accept: application/json
body: null
";
let request = Request::from_yaml_str(yaml).expect("valid yaml should parse");
let expected_headers = vec![("Accept".to_string(), "application/json".to_string())];
assert_eq!(
request,
Request {
name: Some("Get user".to_string()),
method: Method::Get,
url: "https://api.example.com/users/1".to_string(),
headers: expected_headers,
query: Vec::new(),
body: None,
json: None,
body_file: None,
form: Vec::new(),
multipart: Vec::new(),
auth: None,
assertions: None,
pre_request: None,
post_request: None,
capture: None,
retry: None,
}
);
}
#[test]
fn a_header_value_that_is_a_list_expands_to_one_header_per_entry() {
let request = Request::from_yaml_str(
"\
method: GET
url: https://example.com
headers:
Accept: application/json
X-Forwarded-For:
- 1.2.3.4
- 5.6.7.8
",
)
.expect("a list-valued header is part of the file contract");
assert_eq!(
request.headers,
vec![
("Accept".to_string(), "application/json".to_string()),
("X-Forwarded-For".to_string(), "1.2.3.4".to_string()),
("X-Forwarded-For".to_string(), "5.6.7.8".to_string()),
]
);
}
#[test]
fn two_identical_headers_are_kept_not_rejected() {
let request = Request::from_yaml_str(
"\
method: GET
url: https://example.com
headers:
X-Tag:
- same
- same
",
)
.expect("identical repeated headers are allowed, not an error");
assert_eq!(
request.headers,
vec![
("X-Tag".to_string(), "same".to_string()),
("X-Tag".to_string(), "same".to_string()),
]
);
}
#[test]
fn an_unquoted_scalar_header_value_is_still_read_as_a_string() {
let request = Request::from_yaml_str(
"\
method: GET
url: https://example.com
headers:
X-Api-Version: 2
X-Enabled: true
",
)
.expect("an unquoted scalar is a header value, as it always was");
assert_eq!(request.header("X-Api-Version"), Some("2"));
assert_eq!(request.header("X-Enabled"), Some("true"));
}
#[test]
fn a_header_value_that_is_neither_a_scalar_nor_a_list_says_so() {
let err = Request::from_yaml_str(
"\
method: GET
url: https://example.com
headers:
X:
nested: map
",
)
.expect_err("a nested map is not a header value");
let message = std::error::Error::source(&err)
.expect("the serde error is the source")
.to_string();
assert!(
message.contains("expected a string or a list of strings"),
"the message should name the shape a header value may take: {message}"
);
}
#[test]
fn repeated_headers_round_trip_through_yaml() {
let request = Request::from_yaml_str(
"\
method: GET
url: https://example.com
headers:
X-Forwarded-For:
- 1.2.3.4
- 5.6.7.8
",
)
.unwrap();
let yaml = serde_yaml::to_string(&request).expect("a repeated header serialises");
let round_tripped = Request::from_yaml_str(&yaml).expect("and reparses");
assert_eq!(round_tripped.headers, request.headers, "got {yaml}");
}
#[test]
fn parses_a_minimal_request() {
let request = Request::from_yaml_str("method: POST\nurl: https://example.com\n")
.expect("method + url is enough");
assert_eq!(request.method, Method::Post);
assert!(request.headers.is_empty());
assert_eq!(request.body, None);
assert_eq!(
request.assertions, None,
"a file written before assertions existed still parses to no assertions"
);
assert_eq!(request.label(), "POST https://example.com");
}
#[test]
fn parses_a_request_with_an_assertions_block() {
let request = Request::from_yaml_str(
"\
method: GET
url: https://api.example.com/users/1
assertions:
status: 200
headers:
content-type: application/json
x-request-id:
body_contains: ada
json:
$.user.id: 42
",
)
.expect("an assertions block is part of the request shape");
let assertions = request.assertions.expect("the block parsed");
assert_eq!(assertions.status, Some(200));
assert_eq!(
assertions.headers.get("content-type"),
Some(&Some("application/json".to_string()))
);
assert_eq!(assertions.headers.get("x-request-id"), Some(&None));
assert_eq!(assertions.body_contains.as_deref(), Some("ada"));
assert_eq!(assertions.json["$.user.id"], serde_json::json!(42));
}
#[test]
fn an_empty_assertions_block_is_kept_distinct_from_no_block_at_all() {
let empty =
Request::from_yaml_str("method: GET\nurl: https://example.com\nassertions: {}\n")
.unwrap();
assert_eq!(empty.assertions, Some(Assertions::default()));
assert!(empty.assertions.as_ref().unwrap().is_empty());
let null =
Request::from_yaml_str("method: GET\nurl: https://example.com\nassertions:\n").unwrap();
assert_eq!(null.assertions, None);
}
#[test]
fn a_request_with_no_assertions_serialises_without_the_key() {
let request = Request::from_yaml_str("method: GET\nurl: https://example.com\n").unwrap();
let yaml = serde_yaml::to_string(&request).expect("a request serialises");
assert!(!yaml.contains("assertions"), "got {yaml}");
}
#[test]
fn parses_a_request_with_a_capture_block() {
let request = Request::from_yaml_str(
"method: POST
url: https://api.example.com/login
capture:
auth_token: $.token
user_id: $.user.id
",
)
.expect("a capture block is part of the request shape");
let capture = request.capture.expect("the block parsed");
assert_eq!(capture.variables(), vec!["auth_token", "user_id"]);
assert_eq!(
capture.entries()["auth_token"],
crate::capture::CaptureSource::JsonPath("$.token".to_string())
);
assert_eq!(
capture.entries()["user_id"],
crate::capture::CaptureSource::JsonPath("$.user.id".to_string())
);
}
#[test]
fn an_empty_capture_block_is_kept_distinct_from_no_block_at_all() {
let empty = Request::from_yaml_str(
"method: GET
url: https://example.com
capture: {}
",
)
.unwrap();
assert!(empty.capture.as_ref().unwrap().is_empty());
let null = Request::from_yaml_str(
"method: GET
url: https://example.com
capture:
",
)
.unwrap();
assert_eq!(null.capture, None);
}
#[test]
fn a_request_with_no_capture_block_serialises_without_the_key() {
let request = Request::from_yaml_str(
"method: GET
url: https://example.com
",
)
.unwrap();
let yaml = serde_yaml::to_string(&request).expect("a request serialises");
assert!(!yaml.contains("capture"), "got {yaml}");
}
#[test]
fn a_capture_path_is_not_validated_when_the_file_is_loaded() {
let request = Request::from_yaml_str(
"method: GET
url: https://example.com
capture:
v: nonsense
",
)
.expect("the file loads");
assert_eq!(
request.capture.unwrap().entries()["v"],
crate::capture::CaptureSource::JsonPath("nonsense".to_string())
);
}
#[test]
fn malformed_yaml_is_a_parse_error_not_a_panic() {
let err = Request::from_yaml_str("method: [GET\nurl: https://example.com\n")
.expect_err("malformed yaml must not parse");
assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
}
#[test]
fn unknown_method_is_a_parse_error() {
let err = Request::from_yaml_str("method: TELEPORT\nurl: https://example.com\n")
.expect_err("unknown method must not parse");
assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
}
#[test]
fn missing_file_is_an_io_error_carrying_the_path() {
let err = Request::from_path("does/not/exist.yaml").expect_err("missing file must error");
match err {
SendraError::Io { path, .. } => assert_eq!(path, Path::new("does/not/exist.yaml")),
other => panic!("expected Io, got {other:?}"),
}
}
#[test]
fn no_retry_key_at_all_is_none() {
let request = Request::from_yaml_str("method: GET\nurl: https://example.com\n").unwrap();
assert_eq!(request.retry, None);
}
#[test]
fn retry_parses_count_and_an_optional_delay() {
let request = Request::from_yaml_str(
"method: GET\nurl: https://example.com\nretry:\n count: 2\n delay_ms: 250\n",
)
.unwrap();
assert_eq!(
request.retry,
Some(RetryConfig {
count: 2,
delay_ms: Some(250),
})
);
}
#[test]
fn retry_delay_ms_is_optional_and_defaults_to_none() {
let request =
Request::from_yaml_str("method: GET\nurl: https://example.com\nretry:\n count: 3\n")
.unwrap();
assert_eq!(
request.retry,
Some(RetryConfig {
count: 3,
delay_ms: None,
})
);
}
#[test]
fn retry_without_count_is_a_parse_error() {
let err = Request::from_yaml_str(
"method: GET\nurl: https://example.com\nretry:\n delay_ms: 100\n",
)
.expect_err("a `retry` block with no `count` must not parse");
assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
}
#[test]
fn retry_rejects_an_unknown_field() {
let err = Request::from_yaml_str(
"method: GET\nurl: https://example.com\nretry:\n count: 1\n backoff: exponential\n",
)
.expect_err("an unknown `retry` field must not silently parse");
assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
}
}