Compare commits

..

No commits in common. "51da59486e4563e3af4922b5214fcc87d98fb02e" and "a44babb509a9b438ea96f48da9aade98038267f7" have entirely different histories.

3 changed files with 159 additions and 178 deletions

121
README.md
View file

@ -29,58 +29,104 @@ A collection can be super simple
url = "https://api.alemi.dev/debug" url = "https://api.alemi.dev/debug"
``` ```
But more complex options are available, check out provided `postwoman.toml` for some (ready to run!) examples. But more complex options are available
More complex collection trees can be achieved with `include` top level field.
Includes are idempotent and always resolved relative from parent collection's directory.
```toml ```toml
include = [ [client] # HTTP client configuration
"other.toml", user_agent = "postwoman@sample/0.3.1"
"even/more.toml" timeout = 60 # max time for each request to complete, in seconds
redirects = 5 # allow up to five redirects, defaults to none
[env] # these will be replaced in routes options. environment vars overrule these
PW_TOKEN = "set-me-as-and-environment-variable!"
[route.healthcheck] # the simplest possible route: just name and url
url = "https://api.alemi.dev/"
[route.debug]
url = "https://api.alemi.dev/debug"
method = "PUT" # specify request method
query = [ # specify query parameters in a more friendly way
"body=json",
"cache=0"
] ]
headers = [ # add custom headers to request
"Content-Type: application/json",
"Authorization: Bearer ${PW_TOKEN}",
]
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
# note that a bare extractor string is equivalent to `{ type = "jq", query = ".path" }`
[route.benchmark]
url = "https://api.alemi.dev/look/into/the/void"
extract = { type = "discard" } # if you don't care about the output, discard it!
[route.notfound]
url = "https://api.alemi.dev/not-found"
expect = 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
[route.payload]
url = "https://api.alemi.dev/debug"
method = "POST"
body = '''{
"complex": {
"json": "payloads",
"can": "be",
"expressed": "this",
"way": true
}
}'''
extract = { type = "body" } # get the whole response body, this is the default extractor
[route.cookie]
url = "https://api.alemi.dev/getcookie"
method = "GET"
extract = { type = "header", key = "Set-Cookie" } # get a specific response header, ignoring body
``` ```
### Running ### Running
Show collection summary Show collection summary
``` ```
$ postwoman $ postwoman
~@ postwoman/0.3.1 > postwoman@sample/0.2.0
-> postwoman.toml + PW_TOKEN: set-me-as-and-environment-variable!
+ PW_TOKEN=set-me-as-and-environment-variable!
- healthcheck GET https://api.alemi.dev/ - healthcheck: GET https://api.alemi.dev/
- debug PUT https://api.alemi.dev/debug - debug: PUT https://api.alemi.dev/debug
- benchmark GET https://api.alemi.dev/look/into/the/void - benchmark: GET https://api.alemi.dev/look/into/the/void
- notfound GET https://api.alemi.dev/not-found - notfound: GET https://api.alemi.dev/not-found
- payload POST https://api.alemi.dev/debug - payload: POST https://api.alemi.dev/debug
- cookie GET https://api.alemi.dev/getcookie - cookie: GET https://api.alemi.dev/getcookie
``` ```
Run all endpoints matching `.` (aka all of them) Run all endpoints matching `.` (aka all of them)
``` ```
$ postwoman run . $ postwoman run .
~@ postwoman/0.3.1 : [22:27:17.960461] sending healthcheck ...
: [05:14:47.241122] postwoman.toml::healthcheck sending request... + [22:27:18.109843] healthcheck done in 149ms
+ [05:14:47.411708] postwoman.toml::healthcheck done in 170ms
{ {
"example": [ "example": [
"https://api.alemi.dev/debug", "https://api.alemi.dev/debug",
"https://api.alemi.dev/msg", "https://api.alemi.dev/msg",
"https://api.alemi.dev/mumble/ping" "https://api.alemi.dev/mumble/ping"
], ],
"time": "Sunday, 20-Oct-2024 03:14:47 GMT", "time": "Saturday, 19-Oct-2024 20:27:18 GMT",
"up": true "up": true
} }
: [05:14:47.411807] postwoman.toml::debug sending request... : [22:27:18.109924] sending debug ...
+ [05:14:47.574391] postwoman.toml::debug done in 162ms + [22:27:18.268383] debug done in 158ms
/debug?body=json&cache=0 /debug?body=json&cache=0
: [05:14:47.574474] postwoman.toml::benchmark sending request... : [22:27:18.268477] sending benchmark ...
+ [05:14:47.726527] postwoman.toml::benchmark done in 152ms + [22:27:18.422707] benchmark done in 154ms
: [05:14:47.726605] postwoman.toml::notfound sending request... : [22:27:18.422775] sending notfound ...
+ [05:14:47.878922] postwoman.toml::notfound done in 152ms + [22:27:18.575942] notfound done in 153ms
nginx/1.26.2 nginx/1.26.2
: [05:14:47.879000] postwoman.toml::payload sending request... : [22:27:18.576010] sending payload ...
+ [05:14:48.039053] postwoman.toml::payload done in 160ms + [22:27:18.732582] payload done in 156ms
{ {
"body": "{\n\t\"complex\": {\n\t\t\"json\": \"payloads\",\n\t\t\"can\": \"be\",\n\t\t\"expressed\": \"this\",\n\t\t\"way\": true\n\t}\n}", "body": "{\n\t\"complex\": {\n\t\t\"json\": \"payloads\",\n\t\t\"can\": \"be\",\n\t\t\"expressed\": \"this\",\n\t\t\"way\": true\n\t}\n}",
"headers": { "headers": {
@ -89,34 +135,33 @@ nginx/1.26.2
], ],
"connection": "close", "connection": "close",
"content-length": "94", "content-length": "94",
"user-agent": "postwoman@sample/0.3.1", "user-agent": "postwoman@sample/0.2.0",
"x-forwarded-proto": "https", "x-forwarded-proto": "https",
"x-real-ip": "93.34.149.115", "x-real-ip": "93.34.149.115",
"x-real-port": 46945, "x-real-port": 46695,
"x-user-agent": "postwoman@sample/0.3.1" "x-user-agent": "postwoman@sample/0.2.0"
}, },
"method": "POST", "method": "POST",
"path": "/debug", "path": "/debug",
"time": 1729394088.0156112, "time": 1729369638.7079802,
"version": "HTTP/1.0" "version": "HTTP/1.0"
} }
: [05:14:48.039099] postwoman.toml::cookie sending request... : [22:27:18.732676] sending cookie ...
+ [05:14:48.168725] postwoman.toml::cookie done in 129ms + [22:27:18.886862] cookie done in 154ms
SGF2ZSBhIENvb2tpZSE= SGF2ZSBhIENvb2tpZSE=
``` ```
Debug a specific route passing `--debug`: Debug a specific route passing `--debug`:
``` ```
$ postwoman run notfound --debug $ postwoman run notfound --debug
~@ postwoman/0.3.1 : [22:26:59.045642] sending notfound ...
: [05:15:16.960147] postwoman.toml::notfound sending request... + [22:26:59.220103] notfound done in 174ms
+ [05:15:17.120647] postwoman.toml::notfound done in 160ms
Response { Response {
url: "https://api.alemi.dev/not-found", url: "https://api.alemi.dev/not-found",
status: 404, status: 404,
headers: { headers: {
"server": "nginx/1.26.2", "server": "nginx/1.26.2",
"date": "Sun, 20 Oct 2024 03:15:17 GMT", "date": "Sat, 19 Oct 2024 20:26:59 GMT",
"content-type": "text/html", "content-type": "text/html",
"content-length": "153", "content-length": "153",
"connection": "keep-alive", "connection": "keep-alive",

View file

@ -2,8 +2,6 @@ mod model;
mod errors; mod errors;
mod ext; mod ext;
use std::{collections::HashMap, str::FromStr};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
pub use model::PostWomanCollection; pub use model::PostWomanCollection;
@ -17,7 +15,7 @@ pub static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CAR
struct PostWomanArgs { struct PostWomanArgs {
/// collection file to use /// collection file to use
#[arg(short, long, default_value = "postwoman.toml")] #[arg(short, long, default_value = "postwoman.toml")]
collection: std::path::PathBuf, collection: String,
/// action to run /// action to run
#[clap(subcommand)] #[clap(subcommand)]
@ -36,9 +34,13 @@ pub enum PostWomanActions {
query: String, query: String,
/// run requests in parallel /// run requests in parallel
#[arg(short, long, default_value_t = false)] #[arg(long, default_value_t = false)]
parallel: bool, parallel: bool,
/// repeat request N times
#[arg(long, default_value_t = 1)]
repeat: u32,
/// force debug extractor on all routes /// force debug extractor on all routes
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
debug: bool, debug: bool,
@ -46,189 +48,124 @@ pub enum PostWomanActions {
/// show all registered routes in current collection /// show all registered routes in current collection
List { List {
/// show only limited details for each route /// show verbose details for each route
#[arg(short, long, default_value_t = false)] #[arg(short, long, default_value_t = false)]
compact: bool, verbose: bool,
}, },
} }
const TIMESTAMP_FMT: &str = "%H:%M:%S%.6f"; const TIMESTAMP_FMT: &str = "%H:%M:%S%.6f";
fn main() { fn main() -> Result<(), PostWomanError> {
let args = PostWomanArgs::parse(); let args = PostWomanArgs::parse();
let multi_thread = args.multi_threaded;
// if we got a regex, test it early to avoid wasting work when invalid let collection_raw = std::fs::read_to_string(&args.collection)?;
if let Some(PostWomanActions::Run { ref query, .. }) = args.action { let collection: PostWomanCollection = toml::from_str(&collection_raw)?;
// note that if you remove this test, there's another .expect() below you need to manage too!
if let Err(e) = regex::Regex::new(query) {
return eprintln!("! invalid regex filter: {e}");
}
}
let mut collections = HashMap::new(); if args.multi_threaded {
if !load_collections(&mut collections, args.collection.clone()) {
return;
}
let task = async move {
let mut pool = tokio::task::JoinSet::new();
for (collection_name, collection) in collections {
run_postwoman(&args, collection_name, collection, &mut pool).await;
}
while let Some(j) = pool.join_next().await {
match j {
Err(e) => eprintln!("! error joining task: {e}"),
Ok(res) => res.print(),
}
}
};
eprintln!("~@ {APP_USER_AGENT}");
if multi_thread {
tokio::runtime::Builder::new_multi_thread() tokio::runtime::Builder::new_multi_thread()
.enable_all() .enable_all()
.build() .build()
.expect("failed creating tokio multi-thread runtime") .expect("failed creating tokio multi-thread runtime")
.block_on(task) .block_on(async { run_postwoman(args, collection).await })
} else { } else {
tokio::runtime::Builder::new_current_thread() tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build() .build()
.expect("failed creating tokio current-thread runtime") .expect("failed creating tokio current-thread runtime")
.block_on(task) .block_on(async { run_postwoman(args, collection).await })
} }
} }
fn load_collections(store: &mut HashMap<String, PostWomanCollection>, mut path: std::path::PathBuf) -> bool { async fn run_postwoman(args: PostWomanArgs, collection: PostWomanCollection) -> Result<(), PostWomanError> {
let collection_raw = match std::fs::read_to_string(&path) { let action = args.action.unwrap_or(PostWomanActions::List { verbose: false });
Ok(x) => x,
Err(e) => {
eprintln!("! error loading collection {path:?}: {e}");
return false;
},
};
let collection: PostWomanCollection = match toml::from_str(&collection_raw) {
Ok(x) => x,
Err(e) => {
eprintln!("! error parsing collection {path:?}: {e}");
return false;
},
};
let name = path.to_string_lossy().to_string();
if let Some(ref includes) = collection.include {
path.pop();
for include in includes {
let mut base = path.clone();
let new = std::path::PathBuf::from_str(include).expect("infallible");
base.push(new);
if !load_collections(store, base) {
return false;
}
}
}
store.insert(name, collection);
true
}
const DEFAULT_ACTION: PostWomanActions = PostWomanActions::List { compact: true };
type RunResult = (Result<String, PostWomanError>, String, String, chrono::DateTime<chrono::Local>);
async fn run_postwoman(args: &PostWomanArgs, namespace: String, collection: PostWomanCollection, pool: &mut tokio::task::JoinSet<RunResult>) {
let action = args.action.as_ref().unwrap_or(&DEFAULT_ACTION);
match action { match action {
PostWomanActions::List { compact } => { PostWomanActions::List { verbose } => {
println!("-> {namespace}"); let ua = collection.client.user_agent.unwrap_or(APP_USER_AGENT.to_string());
println!("> {ua}");
for (key, value) in collection.env.unwrap_or_default() { for (key, value) in collection.env {
println!(" + {key}={}", ext::stringify_toml(&value)); println!("+ {key}: {}", ext::stringify_toml(&value));
} }
println!();
for (name, mut endpoint) in collection.route { for (name, mut endpoint) in collection.route {
println!(" - {name} \t{} \t{}", endpoint.method.as_deref().unwrap_or("GET"), endpoint.url); println!("- {name}: \t{} \t{}", endpoint.method.as_deref().unwrap_or("GET"), endpoint.url);
if ! *compact { if verbose {
if let Some(ref query) = endpoint.query { if let Some(ref query) = endpoint.query {
for query in query { for query in query {
println!(" |? {query}"); println!(" |? {query}");
} }
} }
if let Some(ref headers) = endpoint.headers { if let Some(ref headers) = endpoint.headers {
for header in headers { for header in headers {
println!(" |: {header}"); println!(" |: {header}");
} }
} }
if let Some(ref _x) = endpoint.body { if let Some(ref _x) = endpoint.body {
if let Ok(body) = endpoint.body() { if let Ok(body) = endpoint.body() {
println!(" |> {}", body.replace("\n", "\n |> ")); println!(" |> {body}");
} else { } else {
println!(" |> [!] invalid body"); println!(" |> [!] invalid body");
} }
} }
} }
} }
println!();
}, },
PostWomanActions::Run { query, parallel, debug } => { PostWomanActions::Run { query, parallel, repeat, debug } => {
// this is always safe to compile because we tested it beforehand let pattern = regex::Regex::new(&query)?;
let pattern = regex::Regex::new(query).expect("tested it before and still failed here???"); let mut joinset = tokio::task::JoinSet::new();
let client = std::sync::Arc::new(collection.client.unwrap_or_default()); let client = std::sync::Arc::new(collection.client);
let env = std::sync::Arc::new(collection.env.unwrap_or_default()); let env = std::sync::Arc::new(collection.env);
for (name, mut endpoint) in collection.route { for (name, mut endpoint) in collection.route {
if pattern.find(&name).is_some() { if pattern.find(&name).is_some() {
if *debug { endpoint.extract = Some(ext::StringOr::T(model::ExtractorConfig::Debug)) }; if debug { endpoint.extract = Some(ext::StringOr::T(model::ExtractorConfig::Debug)) };
let _client = client.clone(); for i in 0..repeat {
let _env = env.clone(); let suffix = if repeat > 1 {
let _endpoint = endpoint.clone(); format!("#{} ", i+1)
let _name = name.clone(); } else {
let _namespace = namespace.clone(); "".to_string()
let task = async move { };
let before = chrono::Local::now(); let _client = client.clone();
eprintln!(" : [{}] {_namespace}::{_name} \tsending request...", before.format(TIMESTAMP_FMT)); let _env = env.clone();
let res = _endpoint let _endpoint = endpoint.clone();
.fill(&_env) let _name = name.clone();
.execute(&_client) let task = async move {
.await; let before = chrono::Local::now();
(res, _namespace, _name, before) eprintln!(" : [{}] sending {_name} {suffix}...", before.format(TIMESTAMP_FMT));
}; let res = _endpoint
if *parallel { .fill(&_env)
pool.spawn(task); .execute(&_client)
} else { .await;
task.await.print(); (res, _name, before, suffix)
};
if parallel {
joinset.spawn(task);
} else {
let (res, name, before, num) = task.await;
print_results(res?, name, before, num);
}
} }
} }
} }
while let Some(j) = joinset.join_next().await {
match j {
Ok((res, name, before, num)) => print_results(res?, name, before, num),
Err(e) => eprintln!("! error joining task: {e}"),
}
}
}, },
} }
Ok(())
} }
trait PrintableResult { fn print_results(res: String, name: String, before: chrono::DateTime<chrono::Local>, suffix: String) {
fn print(self); let after = chrono::Local::now();
} let elapsed = (after - before).num_milliseconds();
let timestamp = after.format(TIMESTAMP_FMT);
impl PrintableResult for RunResult { eprintln!(" + [{timestamp}] {name} {suffix}done in {elapsed}ms", );
fn print(self) { print!("{}", res);
let (result, namespace, name, before) = self;
let success = result.is_ok();
let after = chrono::Local::now();
let elapsed = (after - before).num_milliseconds();
let timestamp = after.format(TIMESTAMP_FMT);
let symbol = if success { " + " } else { "<!>" };
let verb = if success { "done in" } else { "failed after" };
eprintln!("{symbol}[{timestamp}] {namespace}::{name} \t{verb} {elapsed}ms", );
match result {
Ok(x) => print!("{x}"),
Err(e) => eprintln!(" ! {e}"),
}
}
} }

View file

@ -8,9 +8,8 @@ pub use extractor::ExtractorConfig;
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct PostWomanCollection { pub struct PostWomanCollection {
pub client: Option<ClientConfig>, pub client: ClientConfig,
pub env: Option<toml::Table>, pub env: toml::Table,
pub include: Option<Vec<String>>,
// it's weird to name it singular but makes more sense in config // it's weird to name it singular but makes more sense in config
pub route: indexmap::IndexMap<String, EndpointConfig>, pub route: indexmap::IndexMap<String, EndpointConfig>,
} }