Compare commits

...

3 commits

7 changed files with 64 additions and 41 deletions

2
Cargo.lock generated
View file

@ -815,7 +815,7 @@ checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2"
[[package]] [[package]]
name = "postwoman" name = "postwoman"
version = "0.3.2" version = "0.4.0"
dependencies = [ dependencies = [
"base64", "base64",
"chrono", "chrono",

View file

@ -1,7 +1,7 @@
[package] [package]
name = "postwoman" name = "postwoman"
description = "API tester and debugger for your CLI " description = "API tester and debugger for your CLI "
version = "0.3.2" version = "0.4.0"
repository = "https://moonlit.technology/alemi/postwoman" repository = "https://moonlit.technology/alemi/postwoman"
authors = [ "alemi <me@alemi.dev>" ] authors = [ "alemi <me@alemi.dev>" ]
license = "GPL-3.0-only" license = "GPL-3.0-only"

View file

@ -1,18 +1,19 @@
[client] # HTTP client configuration [client] # HTTP client configuration
user_agent = "postwoman@sample/0.3.1" user_agent = "postwoman@sample/0.4.0"
timeout = 60 # max time for each request to complete, in seconds timeout = 60 # max time for each request to complete, in seconds
redirects = 5 # allow up to five redirects, defaults to none redirects = 5 # allow up to five redirects, defaults to none
base = "https://api.alemi.dev" # all route urls will be appended to this base
[env] # these will be replaced in routes options. environment vars overrule these [env] # these will be replaced in routes options. environment vars overrule these
PW_TOKEN = "set-me-as-and-environment-variable!" PW_TOKEN = "set-me-as-and-environment-variable!"
[route.healthcheck] # the simplest possible route: just name and url [route.healthcheck] # the simplest possible route: just name and path
url = "https://api.alemi.dev/" path = "/"
[route.debug] [route.debug]
url = "https://api.alemi.dev/debug" path = "/debug"
method = "PUT" # specify request method method = "PUT" # specify request method
query = [ # specify query parameters in a more friendly way query = [ # specify query parameters in a more friendly way
"body=json", "body=json",
@ -23,20 +24,20 @@ headers = [ # add custom headers to request
"Authorization: Bearer ${PW_TOKEN}", "Authorization: Bearer ${PW_TOKEN}",
] ]
body = { hello = "world!", success = true } # body can be a bare string, or an inline table (will be converted to json) body = { hello = "world!", success = true } # body can be a bare string, or an inline table (will be converted to json)
extract = ".path" # extract from json responses with JQ syntax extract = { type = "body" } # get the whole response body, this is the default extractor
# note that a bare extractor string is equivalent to `{ type = "jq", query = ".path" }`
[route.benchmark] [route.benchmark]
url = "https://api.alemi.dev/look/into/the/void" path = "/look/into/the/void"
extract = { type = "discard" } # if you don't care about the output, discard it! extract = { type = "discard" } # if you don't care about the output, discard it!
[route.notfound] [route.notfound]
url = "https://api.alemi.dev/not-found" path = "https://cdn.alemi.dev/does-not-exist"
expect = 404 # it's possible to specify expected status code, will fail if doesn't match absolute = true # mark as absolute to avoid composing with client base url
status = 404 # it's possible to specify expected status code, will fail if doesn't match
extract = { type = "regex", pattern = 'nginx/[0-9\.]+' } # extract from response with regex extract = { type = "regex", pattern = 'nginx/[0-9\.]+' } # extract from response with regex
[route.payload] [route.payload]
url = "https://api.alemi.dev/debug" path = "/debug"
method = "POST" method = "POST"
body = '''{ body = '''{
"complex": { "complex": {
@ -46,9 +47,10 @@ body = '''{
"way": true "way": true
} }
}''' }'''
extract = { type = "body" } # get the whole response body, this is the default extractor extract = ".path" # extract from json responses with JQ syntax (default extractor), equivalent to `{ type = "jq", query = ".path" }`
expect = "/debug" # if extracted result doesn't match, this route will return an error
[route.cookie] [route.cookie]
url = "https://api.alemi.dev/getcookie" path = "/getcookie"
method = "GET" method = "GET"
extract = { type = "header", key = "Set-Cookie" } # get a specific response header, ignoring body extract = { type = "header", key = "Set-Cookie" } # get a specific response header, ignoring body

View file

@ -34,6 +34,9 @@ pub enum PostWomanError {
#[error("request didn't match expected status code: {0:?}")] #[error("request didn't match expected status code: {0:?}")]
UnexpectedStatusCode(reqwest::Response), UnexpectedStatusCode(reqwest::Response),
#[error("request didn't match expected result: got '{0}' expected '{1}'")]
UnexpectedResult(String, String),
#[error("invalid Json Query: {0}")] #[error("invalid Json Query: {0}")]
JQError(String), JQError(String),

View file

@ -19,7 +19,7 @@ impl PrintableResult for RunResult {
fn print(self) { fn print(self) {
let (result, _namespace, _name, _elapsed) = self; let (result, _namespace, _name, _elapsed) = self;
match result { match result {
Ok(x) => print!("{x}"), Ok(x) => println!("{x}"),
Err(e) => eprintln!(" ! {e}"), Err(e) => eprintln!(" ! {e}"),
} }
} }
@ -63,8 +63,10 @@ impl PrintableResult for ListResult {
println!(" + {key}={}", crate::ext::stringify_toml(&value)); println!(" + {key}={}", crate::ext::stringify_toml(&value));
} }
for (name, mut endpoint) in collection.route { for (name, endpoint) in collection.route {
println!(" - {name} \t{} \t{}", endpoint.method.as_deref().unwrap_or("GET"), endpoint.url); let url = endpoint.url(collection.client.as_ref().and_then(|x| x.base.as_deref()));
let method = endpoint.method.as_deref().unwrap_or("GET");
println!(" - {name} \t{method} \t{url}");
if ! compact { if ! compact {
if let Some(ref query) = endpoint.query { if let Some(ref query) = endpoint.query {
for query in query { for query in query {

View file

@ -1,6 +1,8 @@
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct ClientConfig { pub struct ClientConfig {
/// base url for composing endpoints
pub base: Option<String>,
/// user agent for requests, defaults to 'postwoman/<version>' /// user agent for requests, defaults to 'postwoman/<version>'
pub user_agent: Option<String>, pub user_agent: Option<String>,
/// max total duration of each request, in seconds. defaults to 30 /// max total duration of each request, in seconds. defaults to 30

View file

@ -14,8 +14,10 @@ use super::{ExtractorConfig, ClientConfig};
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct EndpointConfig { pub struct EndpointConfig {
/// endpoint url, required /// endpoint path, composed from client base and query params
pub url: String, pub path: String,
/// absolute url, don't compose with client base url
pub absolute: Option<bool>,
/// http method for request, default GET /// http method for request, default GET
pub method: Option<String>, pub method: Option<String>,
/// query parameters, appended to base url /// query parameters, appended to base url
@ -24,31 +26,33 @@ pub struct EndpointConfig {
pub headers: Option<Vec<String>>, pub headers: Option<Vec<String>>,
/// body, optional string /// body, optional string
pub body: Option<StringOr<toml::Table>>, pub body: Option<StringOr<toml::Table>>,
/// expected error code, will fail if different /// expected error code, will fail if different, defaults to 200
pub expect: Option<u16>, pub status: Option<u16>,
/// response extractor /// response extractor
pub extract: Option<StringOr<ExtractorConfig>>, pub extract: Option<StringOr<ExtractorConfig>>,
/// expected result, will fail if different when provided
pub expect: Option<String>,
} }
impl EndpointConfig { impl EndpointConfig {
pub fn body(&mut self) -> Result<String, serde_json::Error> { pub fn body(&self) -> Result<String, serde_json::Error> {
match self.body.take() { match &self.body {
None => Ok("".to_string()), None => Ok("".to_string()),
Some(StringOr::Str(x)) => Ok(x.clone()), Some(StringOr::Str(x)) => Ok(x.clone()),
Some(StringOr::T(json)) => Ok(serde_json::to_string(&json)?), Some(StringOr::T(json)) => Ok(serde_json::to_string(&json)?),
} }
} }
pub fn method(&mut self) -> Result<reqwest::Method, InvalidMethod> { pub fn method(&self) -> Result<reqwest::Method, InvalidMethod> {
match self.method { match self.method {
Some(ref m) => Ok(reqwest::Method::from_str(m)?), Some(ref m) => Ok(reqwest::Method::from_str(m)?),
None => Ok(reqwest::Method::GET), None => Ok(reqwest::Method::GET),
} }
} }
pub fn headers(&mut self) -> Result<HeaderMap, InvalidHeaderError> { pub fn headers(&self) -> Result<HeaderMap, InvalidHeaderError> {
let mut headers = HeaderMap::default(); let mut headers = HeaderMap::default();
for header in self.headers.take().unwrap_or_default() { for header in self.headers.as_deref().unwrap_or(&[]) {
let (k, v) = header.split_once(':') let (k, v) = header.split_once(':')
.ok_or_else(|| InvalidHeaderError::Format(header.clone()))?; .ok_or_else(|| InvalidHeaderError::Format(header.clone()))?;
headers.insert( headers.insert(
@ -59,9 +63,13 @@ impl EndpointConfig {
Ok(headers) Ok(headers)
} }
pub fn url(&mut self) -> String { pub fn url(&self, base: Option<&str>) -> String {
let mut url = self.url.clone(); let mut url = if self.absolute.unwrap_or(false) {
if let Some(query) = self.query.take() { self.path.clone()
} else {
format!("{}{}", base.unwrap_or_default(), self.path)
};
if let Some(ref query) = self.query {
url = format!("{url}?{}", query.join("&")); url = format!("{url}?{}", query.join("&"));
} }
url url
@ -82,7 +90,7 @@ impl EndpointConfig {
for (k, v) in vars { for (k, v) in vars {
let k_var = format!("${{{k}}}"); let k_var = format!("${{{k}}}");
self.url = self.url.replace(&k_var, &v); self.path = self.path.replace(&k_var, &v);
if let Some(method) = self.method { if let Some(method) = self.method {
self.method = Some(method.replace(&k_var, &v)); self.method = Some(method.replace(&k_var, &v));
} }
@ -118,11 +126,11 @@ impl EndpointConfig {
self self
} }
pub async fn execute(mut self, opts: &ClientConfig) -> Result<String, PostWomanError> { pub async fn execute(self, opts: &ClientConfig) -> Result<String, PostWomanError> {
let url = self.url();
let body = self.body()?; let body = self.body()?;
let method = self.method()?; let method = self.method()?;
let headers = self.headers()?; let headers = self.headers()?;
let url = self.url(opts.base.as_deref());
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.user_agent(opts.user_agent.as_deref().unwrap_or(APP_USER_AGENT)) .user_agent(opts.user_agent.as_deref().unwrap_or(APP_USER_AGENT))
@ -139,26 +147,25 @@ impl EndpointConfig {
.send() .send()
.await?; .await?;
if res.status().as_u16() != self.expect.unwrap_or(200) { if res.status().as_u16() != self.status.unwrap_or(200) {
return Err(PostWomanError::UnexpectedStatusCode(res)); return Err(PostWomanError::UnexpectedStatusCode(res));
} }
Ok(match self.extract.unwrap_or_default() { let res = match self.extract.unwrap_or_default() {
StringOr::T(ExtractorConfig::Discard) => "".to_string(), StringOr::T(ExtractorConfig::Discard) => "".to_string(),
StringOr::T(ExtractorConfig::Body) => format_body(res).await?, StringOr::T(ExtractorConfig::Body) => format_body(res).await?,
StringOr::T(ExtractorConfig::Debug) => { StringOr::T(ExtractorConfig::Debug) => {
// TODO needless double format // TODO needless double format
let res_dbg = format!("{res:#?}"); let res_dbg = format!("{res:#?}");
let body = format_body(res).await?; let body = format_body(res).await?;
format!("{res_dbg}\nBody: {body}\n") format!("{res_dbg}\nBody: {body}")
}, },
StringOr::T(ExtractorConfig::Header { key }) => res StringOr::T(ExtractorConfig::Header { key }) => res
.headers() .headers()
.get(&key) .get(&key)
.ok_or(PostWomanError::HeaderNotFound(key))? .ok_or(PostWomanError::HeaderNotFound(key))?
.to_str()? .to_str()?
.to_string() .to_string(),
+ "\n",
StringOr::T(ExtractorConfig::Regex { pattern }) => { StringOr::T(ExtractorConfig::Regex { pattern }) => {
let pattern = regex::Regex::new(&pattern)?; let pattern = regex::Regex::new(&pattern)?;
let body = format_body(res).await?; let body = format_body(res).await?;
@ -166,19 +173,26 @@ impl EndpointConfig {
.ok_or_else(|| PostWomanError::NoMatch(body.clone()))? .ok_or_else(|| PostWomanError::NoMatch(body.clone()))?
.as_str() .as_str()
.to_string() .to_string()
+ "\n"
}, },
// bare string defaults to JQL query // bare string defaults to JQL query
StringOr::T(ExtractorConfig::JQ { query }) | StringOr::Str(query) => { StringOr::T(ExtractorConfig::JQ { query }) | StringOr::Str(query) => {
let json: serde_json::Value = res.json().await?; let json: serde_json::Value = res.json().await?;
let selection = jq(&query, json)?; let selection = jq(&query, json)?;
if selection.len() == 1 { if selection.len() == 1 {
stringify_json(&selection[0]) + "\n" stringify_json(&selection[0])
} else { } else {
serde_json::to_string_pretty(&selection)? + "\n" serde_json::to_string_pretty(&selection)?
} }
}, },
}) };
if let Some(expected) = self.expect {
if expected != res {
return Err(PostWomanError::UnexpectedResult(res, expected));
}
}
Ok(res)
} }
} }