Compare commits
3 commits
7d56ea3565
...
b6c7c88e5a
Author | SHA1 | Date | |
---|---|---|---|
b6c7c88e5a | |||
5de9e7884e | |||
8fbf224506 |
7 changed files with 64 additions and 41 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -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",
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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),
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue