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"
```
But more complex options are available, check out provided `postwoman.toml` for some (ready to run!) examples.
More complex collection trees can be achieved with `include` top level field.
Includes are idempotent and always resolved relative from parent collection's directory.
But more complex options are available
```toml
include = [
"other.toml",
"even/more.toml"
[client] # HTTP client configuration
user_agent = "postwoman@sample/0.3.1"
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
Show collection summary
```
$ postwoman
~@ postwoman/0.3.1
-> postwoman.toml
+ PW_TOKEN=set-me-as-and-environment-variable!
- healthcheck GET https://api.alemi.dev/
- debug PUT https://api.alemi.dev/debug
- benchmark GET https://api.alemi.dev/look/into/the/void
- notfound GET https://api.alemi.dev/not-found
- payload POST https://api.alemi.dev/debug
- cookie GET https://api.alemi.dev/getcookie
> postwoman@sample/0.2.0
+ PW_TOKEN: set-me-as-and-environment-variable!
- healthcheck: GET https://api.alemi.dev/
- debug: PUT https://api.alemi.dev/debug
- benchmark: GET https://api.alemi.dev/look/into/the/void
- notfound: GET https://api.alemi.dev/not-found
- payload: POST https://api.alemi.dev/debug
- cookie: GET https://api.alemi.dev/getcookie
```
Run all endpoints matching `.` (aka all of them)
```
$ postwoman run .
~@ postwoman/0.3.1
: [05:14:47.241122] postwoman.toml::healthcheck sending request...
+ [05:14:47.411708] postwoman.toml::healthcheck done in 170ms
: [22:27:17.960461] sending healthcheck ...
+ [22:27:18.109843] healthcheck done in 149ms
{
"example": [
"https://api.alemi.dev/debug",
"https://api.alemi.dev/msg",
"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
}
: [05:14:47.411807] postwoman.toml::debug sending request...
+ [05:14:47.574391] postwoman.toml::debug done in 162ms
: [22:27:18.109924] sending debug ...
+ [22:27:18.268383] debug done in 158ms
/debug?body=json&cache=0
: [05:14:47.574474] postwoman.toml::benchmark sending request...
+ [05:14:47.726527] postwoman.toml::benchmark done in 152ms
: [05:14:47.726605] postwoman.toml::notfound sending request...
+ [05:14:47.878922] postwoman.toml::notfound done in 152ms
: [22:27:18.268477] sending benchmark ...
+ [22:27:18.422707] benchmark done in 154ms
: [22:27:18.422775] sending notfound ...
+ [22:27:18.575942] notfound done in 153ms
nginx/1.26.2
: [05:14:47.879000] postwoman.toml::payload sending request...
+ [05:14:48.039053] postwoman.toml::payload done in 160ms
: [22:27:18.576010] sending payload ...
+ [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}",
"headers": {
@ -89,34 +135,33 @@ nginx/1.26.2
],
"connection": "close",
"content-length": "94",
"user-agent": "postwoman@sample/0.3.1",
"user-agent": "postwoman@sample/0.2.0",
"x-forwarded-proto": "https",
"x-real-ip": "93.34.149.115",
"x-real-port": 46945,
"x-user-agent": "postwoman@sample/0.3.1"
"x-real-port": 46695,
"x-user-agent": "postwoman@sample/0.2.0"
},
"method": "POST",
"path": "/debug",
"time": 1729394088.0156112,
"time": 1729369638.7079802,
"version": "HTTP/1.0"
}
: [05:14:48.039099] postwoman.toml::cookie sending request...
+ [05:14:48.168725] postwoman.toml::cookie done in 129ms
: [22:27:18.732676] sending cookie ...
+ [22:27:18.886862] cookie done in 154ms
SGF2ZSBhIENvb2tpZSE=
```
Debug a specific route passing `--debug`:
```
$ postwoman run notfound --debug
~@ postwoman/0.3.1
: [05:15:16.960147] postwoman.toml::notfound sending request...
+ [05:15:17.120647] postwoman.toml::notfound done in 160ms
: [22:26:59.045642] sending notfound ...
+ [22:26:59.220103] notfound done in 174ms
Response {
url: "https://api.alemi.dev/not-found",
status: 404,
headers: {
"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-length": "153",
"connection": "keep-alive",

View file

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

View file

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