feat: starting from scratch

lets start over: simple api testing tool configured with toml files
This commit is contained in:
əlemi 2024-10-19 03:28:16 +02:00
parent d2cb41f755
commit fe7bc43c57
Signed by: alemi
GPG key ID: A4895B84D311642C
15 changed files with 2008 additions and 1059 deletions

1694
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -6,11 +6,13 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-recursion = "1.0.4"
clap = { version = "4.3.0", features = ["derive"] }
postman_collection = "0.2.0"
regex = "1.8.4"
reqwest = { version = "0.11.18", features = ["json"] }
serde = { version = "1.0.163", features = ["derive"] }
serde_json = "1.0.96"
tokio = { version = "1.28.1", features = ["full"] }
clap = { version = "4.5", features = ["derive"] }
http = "1.1.0"
regex = "1.11"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0.64"
tokio = { version = "1.40", features = ["full"] }
toml = "0.8"
toml_edit = { version = "0.22", features = ["serde"] } # only to pretty print tables ...

182
README.md
View file

@ -1,169 +1,41 @@
# PostWoman
A CLI api tester and request builder, compatible with Postman collection format
A CLI api tester and request builder, totally not born out of frustration from some other tool...
## Why
Since newer postman requires registration to use most useful features, I decided to build my own tool reusing their format, to be compatible with my coworkers' collections.
Also, I'd much rather use a CLI tool than a bundled webpage.
I'd much rather edit my test routes in my text editor as bare config files and fire them via a CLI than fumble around some GUI application.
# Usage
Add `alias pw=postwoman` to your `.bashrc` because I'll be referring to it as `pw` from now on.
`postwoman` expects a `postwoman.toml` collection in your cwd. A different file or path can be specified with the global `-c` option.
`pw` expects a `postwoman.json` collection in your cwd. A different file or path can be specified with the global `-c` option.
Use `postwoman run <filter>` to send requests to all routes in current config matching given filter (regex). Use `.` as filter to run all.
`pw` supports 2 actions as of now:
## Examples
A collection can be super simple
* `show` which will display on your CLI the collection structure
* `test` which will execute all requests concurrently
Both actions support a `-v` switch to print more stuff (body, headers, descriptions...) and a `-p` switch to prettify json outputs.
Both actions also work with a filter: just type your regex as argument and only requests with matching urls will be displayed/executed
# Examples
All coming examples are run with provided example `postwoman.json` in their cwd.
### Show
```
$ pw show
─┐ Sample Postman Collection
├ * GET https://api.alemi.dev/dump?source=sample-collection
├─┐ POST requests
│ ├ * POST https://api.alemi.dev/dump
│ ├ * POST https://api.alemi.dev/dump
│ ╵
```toml
[route.test]
url = "https://api.alemi.dev/debug"
```
```
$ pw show -v
─┐ Sample Postman Collection
│ A sample collection to demonstrate collections as a set of related requests
├ * GET https://api.alemi.dev/dump?source=sample-collection
├─┐ POST requests
│ │
│ ├ * POST https://api.alemi.dev/dump
│ │ [ content-type:text/plain ]
│ │ Duis posuere augue vel cursus pharetra. In luctus a ex nec pretium...
│ │
│ ├ * POST https://api.alemi.dev/dump
│ │ [ content-type:application/json ]
│ │ {"length":100,"text":"Lorem ipsum"}
│ │
│ ╵
```
But more complex options are available
```
$ pw show -v -p
─┐ Sample Postman Collection
│ A sample collection to demonstrate collections as a set of related requests
├ * GET https://api.alemi.dev/dump?source=sample-collection
├─┐ POST requests
│ │
│ ├ * POST https://api.alemi.dev/dump
│ │ [
│ │ content-type:text/plain
│ │ ]
│ │ Duis posuere augue vel cursus pharetra. In luctus a ex nec pretium...
│ │
│ ├ * POST https://api.alemi.dev/dump
│ │ [
│ │ content-type:application/json
│ │ ]
│ │ {
│ │ "length": 100,
│ │ "text": "Lorem ipsum"
│ │ }
│ │
│ ╵
```
```toml
[client]
user_agent = "api-tester@alemi.dev"
### Test
[route.debug]
url = "https://api.alemi.dev/debug"
method = "PUT"
headers = ["Content-Type: application/json"]
body = { hello = "world!", success = true }
extract = { type = "body" }
```
$ pw test
─┐ Sample Postman Collection
├ ✓ 200 >> GET https://api.alemi.dev/dump?source=sample-collection
├─┐ POST requests
│ ├ ✓ 200 >> POST https://api.alemi.dev/dump
│ ├ ✓ 200 >> POST https://api.alemi.dev/dump
│ ╵
```
```
$ pw test -v -p
─┐ Sample Postman Collection
│ A sample collection to demonstrate collections as a set of related requests
├ ✓ 200 >> GET https://api.alemi.dev/dump?source=sample-collection
│ {
│ "body": "",
│ "headers": {
│ "accept": [
│ "*/*"
│ ],
│ "connection": "close",
│ "user-agent": "postwoman/0.2.0",
│ "x-forwarded-proto": "https",
│ "x-real-ip": "xxx.xxx.xxx.xxx",
│ "x-real-port": xxxxx
│ },
│ "method": "GET",
│ "path": "/dump?source=sample-collection",
│ "time": 0.2629528,
│ "version": "HTTP/1.0"
│ }
├─┐ POST requests
│ │
│ ├ ✓ 200 >> POST https://api.alemi.dev/dump
│ │ {
│ │ "body": "Duis posuere augue vel cursus pharetra. In luctus a ex nec pretium...",
│ │ "headers": {
│ │ "accept": [
│ │ "*/*"
│ │ ],
│ │ "connection": "close",
│ │ "content-length": "69",
│ │ "content-type": "text/plain",
│ │ "user-agent": "postwoman/0.2.0",
│ │ "x-forwarded-proto": "https",
│ │ "x-real-ip": "xxx.xxx.xxx.xxx",
│ │ "x-real-port": xxxxx
│ │ },
│ │ "method": "POST",
│ │ "path": "/dump",
│ │ "time": 0.2708838,
│ │ "version": "HTTP/1.0"
│ │ }
│ │
│ ├ ✓ 200 >> POST https://api.alemi.dev/dump
│ │ {
│ │ "body": "{\"text\":\"Lorem ipsum\", \"length\":100}",
│ │ "headers": {
│ │ "accept": [
│ │ "*/*"
│ │ ],
│ │ "connection": "close",
│ │ "content-length": "36",
│ │ "content-type": "application/json",
│ │ "user-agent": "postwoman/0.2.0",
│ │ "x-forwarded-proto": "https",
│ │ "x-real-ip": "xxx.xxx.xxx.xxx",
│ │ "x-real-port": xxxxx
│ │ },
│ │ "method": "POST",
│ │ "path": "/dump",
│ │ "time": 0.2888672,
│ │ "version": "HTTP/1.0"
│ │ }
│ │
│ ╵
[route.cookie]
url = "https://api.alemi.dev/getcookie"
method = "GET"
headers = [
"Authorization: Basic ...",
"Accept: application/json"
]
extract = { type = "header", key = "Set-Cookie" }
```

View file

@ -1,12 +1,9 @@
pub mod model;
mod requestor;
mod printer;
mod model;
use std::collections::HashMap;
use clap::{Parser, Subcommand};
use regex::Regex;
use crate::{model::PostWomanCollection, requestor::send_requests , printer::{show_results, show_requests}};
use model::{Endpoint, Extractor, PostWomanClient, PostWomanConfig, PostWomanError, StringOr};
static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
@ -14,168 +11,107 @@ static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_P
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct PostWomanArgs {
/// collection to use
#[arg(short, long, default_value = "postwoman.json")]
/// collection file to use
#[arg(short, long, default_value = "postwoman.toml")]
collection: String,
/// Action to run
/// action to run
#[clap(subcommand)]
action: PostWomanActions,
}
#[derive(Subcommand, Debug)]
pub enum PostWomanActions {
/// run a single request to given url
// Send {
// /// request URL
/// print an example configragion, pipe to file and start editing
Sample,
/// execute specific endpoint requests
Run {
/// regex query filter, run all with '.*'
query: String,
},
// Save {
// /// name for new endpoint
// name: String,
// /// url of endpoint
// url: String,
// /// request method
// #[arg(short = 'X', long, default_value_t = Method::GET)]
// method: Method,
// /// headers for request
// #[arg(short = 'H', long, num_args = 0..)]
// /// method
// method: Option<String>,
// /// headers
// headers: Vec<String>,
// /// request body
// #[arg(short, long, default_value = "")]
// data: String,
// /// add action to collection items
// #[arg(short = 'S', long, default_value_t = false)]
// save: bool,
// },
/// run all saved requests
Test {
/// filter requests to fire by url (regex)
filter: Option<String>,
/// isolate each request client from others
#[arg(long, default_value_t = false)]
isolated: bool,
/// pretty-print json outputs
#[arg(short, long, default_value_t = false)]
pretty: bool,
/// show response body of each request
#[arg(short, long, default_value_t = false)]
verbose: bool,
/// don't make any real request
#[arg(long, default_value_t = false)]
dry_run: bool,
},
/// list saved requests
Show {
/// filter requests to display by url (regex)
filter: Option<String>,
/// pretty-print json outputs
#[arg(short, long, default_value_t = false)]
pretty: bool,
/// show response body of each request
#[arg(short, long, default_value_t = false)]
verbose: bool,
},
// /// body
// body: Option<String>,
// }
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
async fn main() -> Result<(), PostWomanError> {
let args = PostWomanArgs::parse();
let collection = PostWomanCollection::from_path(&args.collection)?;
if matches!(args.action, PostWomanActions::Sample) {
let a = Endpoint {
url: "https://api.alemi.dev/debug".into(),
query: None,
method: None,
headers: None,
body: None,
extract: None,
};
let b = Endpoint {
url: "https://api.alemi.dev/debug".into(),
query: None,
method: Some("PUT".into()),
headers: Some(vec![
"Authorization: Bearer asdfg".into(),
"Cache: skip".into(),
]),
body: Some(StringOr::T(toml::Table::from_iter([("hello".into(), toml::Value::String("world".into()))]))),
extract: Some(StringOr::T(Extractor::Body)),
};
let client = PostWomanClient {
user_agent: Some(APP_USER_AGENT.into()),
};
let cfg = PostWomanConfig {
client,
route: HashMap::from_iter([
("simple".to_string(), a),
("json".to_string(), b),
]),
};
println!("{}", toml_edit::ser::to_string_pretty(&cfg)?);
return Ok(());
}
let collection = std::fs::read_to_string(args.collection)?;
let config: PostWomanConfig = toml::from_str(&collection)?;
match args.action {
// PostWomanActions::Send {
// url, headers, method, data, save
// } => {
// let req = Request::Object {
// url: crate::proto::Url::String(url),
// method: method.to_string(),
// header: Some(
// headers
// .chunks(2)
// .map(|x| Header {
// key: x[0].clone(),
// value: x[1].clone(), // TODO panics
// })
// .collect(),
// ),
// body: if data.len() > 0 { Some(Body::String(data)) } else { None },
// description: None,
// };
PostWomanActions::Run { query } => {
let pattern = regex::Regex::new(&query)?;
for (name, endpoint) in config.route {
if pattern.find(&name).is_some() {
eprintln!("> executing {name}");
let res = endpoint
.fill()
.execute()
.await?;
println!("{res}");
}
}
},
// let res = req.clone().send().await?;
// if args.verbose {
// println!(" ├┐ {}", res.status());
// }
// if args.verbose {
// println!(" ││ {}", res.text().await?.replace("\n", "\n ││ "));
// } else {
// println!("{}", res.text().await?);
// }
// if save {
// // TODO prompt for name and descr
// let item = Item {
// name: "TODO!".into(),
// event: None,
// item: None,
// request: Some(req),
// response: Some(vec![]),
// };
// collection.item.push(item);
// std::fs::write(&args.collection, serde_json::to_string(&collection)?)?;
// if args.verbose { println!(" ││ * saved") }
// }
// if args.verbose { println!(" │╵") }
// PostWomanActions::Save { name, url, method, headers, body } => {
// todo!();
// },
PostWomanActions::Test { filter, isolated, pretty, verbose, dry_run } => {
let matcher = match filter {
Some(rex) => Some(Regex::new(&rex)?),
None => None,
};
let client = if isolated { None } else {
Some(
reqwest::Client::builder()
.user_agent(APP_USER_AGENT)
.build()
.unwrap()
)
};
match collection.requests(matcher.as_ref()) {
Some(tree) => {
let results = send_requests(tree, client, dry_run).await;
show_results(results, verbose, pretty).await;
},
None => {
eprintln!("[!] no requests match given filter");
}
}
},
PostWomanActions::Show { filter, verbose, pretty } => {
let matcher = match filter {
Some(rex) => Some(Regex::new(&rex)?),
None => None,
};
match collection.requests(matcher.as_ref()) {
Some(tree) => {
show_requests(tree, verbose, pretty);
},
None => {
eprintln!("[!] no requests match given filter");
}
}
},
PostWomanActions::Sample => unreachable!(),
}
Ok(())
}

192
src/model.rs Normal file
View file

@ -0,0 +1,192 @@
use std::{collections::HashMap, str::FromStr};
use reqwest::{header::{HeaderMap, HeaderName, HeaderValue}};
#[derive(Debug, thiserror::Error)]
pub enum PostWomanError {
#[error("network error: {0:?}")]
Request(#[from] reqwest::Error),
#[error("invalid method: {0:?}")]
InvalidMethod(#[from] http::method::InvalidMethod),
#[error("invalid header name: {0:?}")]
InvalidHeaderName(#[from] reqwest::header::InvalidHeaderName),
#[error("invalid header value: {0:?}")]
InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
#[error("contains Unprintable characters: {0:?}")]
Unprintable(#[from] reqwest::header::ToStrError),
#[error("header '{0}' not found in response")]
HeaderNotFound(String),
#[error("invalid header: '{0}'")]
InvalidHeader(String),
#[error("error opening collection: {0:?}")]
ErrorOpeningCollection(#[from] std::io::Error),
#[error("collection is not valid toml: {0:?}")]
InvalidCollection(#[from] toml::de::Error),
#[error("could not represent collection: {0:?}")] // should never happen
ErrorSerializingInternallyCollection(#[from] toml_edit::ser::Error),
#[error("invalid json payload: {0:?}")]
InvalidJson(#[from] serde_json::Error),
#[error("invalid regex: {0:?}")]
InvalidRegex(#[from] regex::Error),
}
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct Endpoint {
/// endpoint url, required
pub url: String,
/// http method for request, default GET
pub method: Option<String>,
/// query parameters, appended to base url
pub query: Option<Vec<String>>,
/// headers for request, array of "key: value" pairs
pub headers: Option<Vec<String>>,
/// body, optional string
pub body: Option<StringOr<toml::Table>>,
/// response extractor
pub extract: Option<StringOr<Extractor>>,
}
fn replace_recursive(element: toml::Value, from: &str, to: &str) -> toml::Value {
match element {
toml::Value::Float(x) => toml::Value::Float(x),
toml::Value::Integer(x) => toml::Value::Integer(x),
toml::Value::Boolean(x) => toml::Value::Boolean(x),
toml::Value::Datetime(x) => toml::Value::Datetime(x),
toml::Value::String(x) => toml::Value::String(x.replace(from, to)),
toml::Value::Array(x) => toml::Value::Array(
x.into_iter().map(|x| replace_recursive(x, from, to)).collect()
),
toml::Value::Table(map) => {
let mut out = toml::map::Map::new();
for (k, v) in map {
let new_v = replace_recursive(v.clone(), from, to);
if k.contains(from) {
out.insert(k.replace(from, to), new_v);
} else {
out.insert(k.to_string(), new_v);
}
}
toml::Value::Table(out)
},
}
}
impl Endpoint {
pub fn fill(mut self) -> Self {
for (k, v) in std::env::vars() {
let k_var = format!("${k}");
self.url = self.url.replace(&k_var, &v);
if let Some(method) = self.method {
self.method = Some(method.replace(&k_var, &v));
}
if let Some(b) = self.body {
match b {
StringOr::Str(body) => {
self.body = Some(StringOr::Str(body.replace(&k_var, &v)));
},
StringOr::T(json) => {
let wrap = toml::Value::Table(json.clone());
let toml::Value::Table(out) = replace_recursive(wrap, &k_var, &v)
else { unreachable!("we put in a table, we get out a table") };
self.body = Some(StringOr::T(out));
},
}
}
if let Some(headers) = self.headers {
self.headers = Some(
headers.into_iter()
.map(|x| x.replace(&k_var, &v))
.collect()
);
}
}
self
}
pub async fn execute(self) -> Result<String, PostWomanError> {
let method = match self.method {
Some(m) => reqwest::Method::from_str(&m)?,
None => reqwest::Method::GET,
};
let mut headers = HeaderMap::default();
for header in self.headers.unwrap_or_default() {
let (k, v) = header.split_once(':')
.ok_or_else(|| PostWomanError::InvalidHeader(header.clone()))?;
headers.insert(
HeaderName::from_str(k)?,
HeaderValue::from_str(v)?
);
}
let body = match self.body.unwrap_or_default() {
StringOr::Str(x) => x,
StringOr::T(json) => serde_json::to_string(&json)?,
};
let res = reqwest::Client::new()
.request(method, self.url)
.headers(headers)
.body(body)
.send()
.await?
.error_for_status()?;
Ok(match self.extract.unwrap_or_default() {
StringOr::Str(query) => todo!(),
StringOr::T(Extractor::Debug) => format!("{res:#?}"),
StringOr::T(Extractor::Body) => res.text().await?,
StringOr::T(Extractor::Header { key }) => res
.headers()
.get(&key)
.ok_or(PostWomanError::HeaderNotFound(key))?
.to_str()?
.to_string(),
})
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(untagged)]
pub enum StringOr<T> {
Str(String),
T(T),
}
impl<T: Default> Default for StringOr<T> {
fn default() -> Self {
Self::T(T::default())
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Extractor {
#[default]
Debug,
Body,
// JQL { query: String },
// Regex { pattern: String },
Header { key: String },
}
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct PostWomanClient {
pub user_agent: Option<String>,
}
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct PostWomanConfig {
pub client: PostWomanClient,
// it's weird to name it singular but makes more sense in config
pub route: HashMap<String, Endpoint>,
}

View file

@ -1,124 +0,0 @@
use postman_collection::{v1_0_0, v2_0_0, v2_1_0};
use regex::Regex;
use super::{request::IntoRequest, description::IntoOptionalString};
pub enum RequestNode {
Leaf(reqwest::Request),
Branch(Vec<RequestTree>),
}
pub struct RequestTree {
pub name: String,
pub description: Option<String>,
pub request: RequestNode,
}
pub trait CollectRequests {
fn collect_requests(&self, filter: Option<&Regex>) -> Option<RequestTree>;
}
impl CollectRequests for v1_0_0::Spec {
fn collect_requests(&self, _filter: Option<&Regex>) -> Option<RequestTree> {
todo!()
}
}
impl CollectRequests for v2_0_0::Spec {
fn collect_requests(&self, filter: Option<&Regex>) -> Option<RequestTree> {
let requests = self.item.iter()
.filter_map(|x| x.collect_requests(filter))
.collect::<Vec<RequestTree>>();
(!requests.is_empty())
.then(|| RequestTree {
name: self.info.name.clone(),
description: self.info.description.as_ref().map_or(None, |x| x.as_string()),
request: RequestNode::Branch(requests),
}
)
}
}
impl CollectRequests for v2_1_0::Spec {
fn collect_requests(&self, filter: Option<&Regex>) -> Option<RequestTree> {
let requests = self.item.iter()
.filter_map(|x| x.collect_requests(filter))
.collect::<Vec<RequestTree>>();
(!requests.is_empty())
.then(||
RequestTree {
name: self.info.name.clone(),
description: self.info.description.as_ref().map_or(None, |x| x.as_string()),
request: RequestNode::Branch(requests),
}
)
}
}
// impl CollectRequests for v1_0_0::Items {
// fn collect_requests(&self) -> Vec<reqwest::Request> {
// todo!()
// }
// }
impl CollectRequests for v2_0_0::Items {
fn collect_requests(&self, filter: Option<&Regex>) -> Option<RequestTree> {
if self.request.is_some() && self.item.is_some() {
panic!("node has both a single request and child requests!");
}
let name = self.name.as_ref().unwrap_or(&"".to_string()).to_string();
let description = self.description.as_ref().map_or(None, |x| x.as_string());
if let Some(r) = &self.request {
let clazz = match r {
v2_0_0::RequestUnion::String(url) => v2_0_0::RequestClass {
url: Some(v2_0_0::Url::String(url.clone())),
.. Default::default()
},
v2_0_0::RequestUnion::RequestClass(r) => r.clone(),
};
Some(RequestTree { name, description, request: RequestNode::Leaf(clazz.make_request(filter)?) })
} else if let Some(sub) = &self.item {
let requests = sub.iter()
.filter_map(|x| x.collect_requests(filter))
.collect::<Vec<RequestTree>>();
(!requests.is_empty())
.then(|| RequestTree { name, description, request: RequestNode::Branch(requests) })
} else {
None
}
}
}
impl CollectRequests for v2_1_0::Items {
fn collect_requests(&self, filter: Option<&Regex>) -> Option<RequestTree> {
if self.request.is_some() && self.item.is_some() {
panic!("node has both a single request and child requests!");
}
let name = self.name.as_ref().unwrap_or(&"".to_string()).to_string();
let description = self.description.as_ref().map_or(None, |x| x.as_string());
if let Some(r) = &self.request {
let clazz = match r {
v2_1_0::RequestUnion::String(url) => v2_1_0::RequestClass {
auth: None,
body: None,
certificate: None,
description: None,
header: None,
method: None,
proxy: None,
url: Some(v2_1_0::Url::String(url.clone())),
},
v2_1_0::RequestUnion::RequestClass(r) => r.clone(),
};
Some(RequestTree { name, description, request: RequestNode::Leaf(clazz.make_request(filter)?) })
} else if let Some(sub) = &self.item {
let requests = sub.iter()
.filter_map(|x| x.collect_requests(filter))
.collect::<Vec<RequestTree>>();
(!requests.is_empty())
.then(|| RequestTree { name, description, request: RequestNode::Branch(requests) })
} else {
None
}
}
}

View file

@ -1,27 +0,0 @@
use postman_collection::{v2_0_0, v2_1_0};
pub trait IntoOptionalString {
fn as_string(&self) -> Option<String>;
}
impl IntoOptionalString for v2_0_0::DescriptionUnion {
fn as_string(&self) -> Option<String> {
match self {
v2_0_0::DescriptionUnion::String(x) => Some(x.clone()),
v2_0_0::DescriptionUnion::Description(
v2_0_0::Description { content, .. }
) => content.clone(),
}
}
}
impl IntoOptionalString for v2_1_0::DescriptionUnion {
fn as_string(&self) -> Option<String> {
match self {
v2_1_0::DescriptionUnion::String(x) => Some(x.clone()),
v2_1_0::DescriptionUnion::Description(
v2_1_0::Description { content, .. }
) => content.clone(),
}
}
}

View file

@ -1,32 +0,0 @@
use postman_collection::{v1_0_0, v2_0_0, v2_1_0};
pub trait IntoHost {
fn make_host(&self) -> String;
}
impl IntoHost for v2_1_0::Host {
fn make_host(&self) -> String {
match self {
v2_1_0::Host::String(x) => x.clone(),
v2_1_0::Host::StringArray(v) => v.join("."),
}
}
}
impl IntoHost for v2_0_0::Host {
fn make_host(&self) -> String {
match self {
v2_0_0::Host::String(x) => x.clone(),
v2_0_0::Host::StringArray(v) => v.join("."),
}
}
}
impl IntoHost for v1_0_0::Host {
fn make_host(&self) -> String {
match self {
v1_0_0::Host::String(x) => x.clone(),
v1_0_0::Host::StringArray(v) => v.join("."),
}
}
}

View file

@ -1,64 +0,0 @@
pub mod request;
pub mod collector;
pub mod description;
use postman_collection::{PostmanCollection, v2_0_0, v2_1_0};
use regex::Regex;
use self::collector::{CollectRequests, RequestTree};
#[derive(Debug)]
pub struct PostWomanCollection {
collection: PostmanCollection
}
impl From<PostmanCollection> for PostWomanCollection {
fn from(value: PostmanCollection) -> Self {
Self { collection: value }
}
}
impl PostWomanCollection {
pub fn from_path(path: &str) -> postman_collection::errors::Result<Self> {
Ok(postman_collection::from_path(path)?.into())
}
pub fn name(&self) -> &String {
match &self.collection {
PostmanCollection::V1_0_0(_spec) => todo!(),
PostmanCollection::V2_0_0(spec) => &spec.info.name,
PostmanCollection::V2_1_0(spec) => &spec.info.name,
}
}
pub fn description(&self) -> Option<&String> {
match &self.collection {
PostmanCollection::V1_0_0(spec) => {
spec.description.as_ref()
},
PostmanCollection::V2_0_0(spec) => {
match &spec.info.description {
Some(v2_0_0::DescriptionUnion::String(x)) => Some(x),
Some(v2_0_0::DescriptionUnion::Description(v2_0_0::Description { content, .. })) => content.as_ref(),
None => None,
}
},
PostmanCollection::V2_1_0(spec) => {
match &spec.info.description {
Some(v2_1_0::DescriptionUnion::String(x)) => Some(x),
Some(v2_1_0::DescriptionUnion::Description(v2_1_0::Description { content, .. })) => content.as_ref(),
None => None,
}
},
}
}
pub fn requests(&self, filter: Option<&Regex>) -> Option<RequestTree> {
match &self.collection {
PostmanCollection::V1_0_0(_) => todo!(),
PostmanCollection::V2_0_0(spec) => spec.collect_requests(filter),
PostmanCollection::V2_1_0(spec) => spec.collect_requests(filter),
}
}
}

View file

@ -1,37 +0,0 @@
use postman_collection::{v1_0_0, v2_0_0, v2_1_0};
pub trait IntoPath {
fn make_path(&self) -> String;
}
impl IntoPath for v1_0_0::UrlPath {
fn make_path(&self) -> String {
todo!()
}
}
impl IntoPath for v2_0_0::UrlPath {
fn make_path(&self) -> String {
todo!()
}
}
impl IntoPath for v2_1_0::UrlPath {
fn make_path(&self) -> String {
match self {
v2_1_0::UrlPath::String(x) => x.clone(),
v2_1_0::UrlPath::UnionArray(v) => {
let mut out = String::new();
for p in v {
match p {
v2_1_0::PathElement::PathClass(v2_1_0::PathClass { value: Some(x), ..}) => out.push_str(&x),
v2_1_0::PathElement::String(x) => out.push_str(&x),
_ => {},
}
out.push('/');
}
out
},
}
}
}

View file

@ -1,32 +0,0 @@
use postman_collection::{v1_0_0, v2_0_0, v2_1_0};
pub trait IntoQueryString {
fn make_query(&self) -> String;
}
impl IntoQueryString for Vec<v2_1_0::QueryParam> {
fn make_query(&self) -> String {
self.iter()
.filter_map(|x| Some(format!("{}={}", x.key?, x.value?)))
.collect::<Vec<String>>()
.join("&")
}
}
impl IntoQueryString for Vec<v2_0_0::QueryParam> {
fn make_query(&self) -> String {
self.iter()
.filter_map(|x| Some(format!("{}={}", x.key?, x.value?)))
.collect::<Vec<String>>()
.join("&")
}
}
impl IntoQueryString for Vec<v1_0_0::QueryParam> {
fn make_query(&self) -> String {
self.iter()
.filter_map(|x| Some(format!("{}={}", x.key?, x.value?)))
.collect::<Vec<String>>()
.join("&")
}
}

View file

@ -1,160 +0,0 @@
use std::str::FromStr;
use postman_collection::{v1_0_0, v2_0_0, v2_1_0};
use regex::Regex;
fn fill_from_env(mut txt: String) -> String {
for (k, v) in std::env::vars() {
let key = format!("{{{{{}}}}}", k); // escaping of { is done by repeating them
if txt.contains(&key) {
txt = txt.replace(&key, &v);
}
}
txt
}
pub trait IntoRequest {
fn make_request(&self, filter: Option<&Regex>) -> Option<reqwest::Request>;
}
impl IntoRequest for v2_0_0::RequestClass {
fn make_request(&self, filter: Option<&Regex>) -> Option<reqwest::Request> {
let method = reqwest::Method::from_bytes(
self.method.as_ref().unwrap_or(&"GET".into()).as_bytes() // TODO lol?
).unwrap_or(reqwest::Method::GET); // TODO throw an error rather than replacing it silently
let mut url_str = match &self.url {
Some(v2_0_0::Url::String(x)) => x.clone(),
Some(v2_0_0::Url::UrlClass(v2_0_0::UrlClass { raw: Some(x), .. })) => x.clone(),
// TODO compose URL from UrlClass rather than only accepting those with raw set
_ => "http://localhost".into(),
};
url_str = fill_from_env(url_str);
if filter.is_some() && !filter.unwrap().is_match(&url_str) {
return None;
}
let url = reqwest::Url::from_str(&url_str).unwrap_or_else(|e| {
eprintln!("error creating url ({}), falling back to localhost", e);
reqwest::Url::from_str("http://localhost/").unwrap()
});
let mut request = reqwest::Request::new(method, url);
// TODO handle more auth types than just bearer
if let Some(auth) = &self.auth {
if let Some(bearers) = &auth.bearer {
let headers = request.headers_mut();
for v in bearers.values() {
if let Some(value) = &v {
headers.insert(
"Authorization",
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", value.as_str().unwrap_or(&value.to_string()))).unwrap() // TODO lmao meme
);
}
}
}
}
match &self.header {
Some(v2_0_0::HeaderUnion::HeaderArray(x)) => {
let headers = request.headers_mut();
for h in x {
let k = fill_from_env(h.key.clone());
let v = fill_from_env(h.value.clone());
headers.insert(
reqwest::header::HeaderName::from_str(&k).unwrap(),
reqwest::header::HeaderValue::from_str(&v).unwrap()
); // TODO avoid cloning
}
},
_ => {},
}
match &self.body {
Some(v2_0_0::Body { raw: Some(x), .. }) => {
*request.body_mut() = Some(reqwest::Body::from(fill_from_env(x.clone()))) // TODO try to avoid cloning?
},
_ => {},
}
Some(request)
}
}
impl IntoRequest for v2_1_0::RequestClass {
fn make_request(&self, filter: Option<&Regex>) -> Option<reqwest::Request> {
let method = reqwest::Method::from_bytes(
self.method.as_ref().unwrap_or(&"GET".into()).as_bytes() // TODO lol?
).unwrap_or(reqwest::Method::GET); // TODO throw an error rather than replacing it silently
let mut url_str = match &self.url {
Some(v2_1_0::Url::String(x)) => x.clone(),
Some(v2_1_0::Url::UrlClass(v2_1_0::UrlClass { raw: Some(x), .. })) => x.clone(),
// TODO compose URL from UrlClass rather than only accepting those with raw set
_ => "http://localhost".into(),
};
url_str = fill_from_env(url_str);
if filter.is_some() && !filter.unwrap().is_match(&url_str) {
return None;
}
let url = reqwest::Url::from_str(&url_str).unwrap_or_else(|e| {
eprintln!("error creating url ({}), falling back to localhost", e);
reqwest::Url::from_str("http://localhost/").unwrap()
});
let mut request = reqwest::Request::new(method, url);
// TODO handle more auth types than just bearer
if let Some(auth) = &self.auth {
if let Some(bearers) = &auth.bearer {
let headers = request.headers_mut();
for bearer in bearers {
if let Some(value) = &bearer.value {
headers.insert(
"Authorization",
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", value.as_str().unwrap_or(&value.to_string()))).unwrap() // TODO lmao meme
);
}
}
}
}
match &self.header {
Some(v2_1_0::HeaderUnion::HeaderArray(x)) => {
let headers = request.headers_mut();
for h in x {
let k = fill_from_env(h.key.clone());
let v = fill_from_env(h.value.clone());
headers.insert(
reqwest::header::HeaderName::from_str(&k).unwrap(),
reqwest::header::HeaderValue::from_str(&v).unwrap()
); // TODO avoid cloning
}
},
_ => {},
}
match &self.body {
Some(v2_1_0::Body { raw: Some(x), .. }) => {
*request.body_mut() = Some(reqwest::Body::from(fill_from_env(x.clone()))) // TODO try to avoid cloning?
},
_ => {},
}
Some(request)
}
}
impl IntoRequest for v1_0_0::Request {
fn make_request(&self, _filter: Option<&Regex>) -> Option<reqwest::Request> {
todo!()
}
}

View file

@ -1,69 +0,0 @@
use postman_collection::{v1_0_0, v2_0_0, v2_1_0};
use super::{query::IntoQueryString, host::IntoHost, path::IntoPath};
pub trait IntoUrl {
fn make_url(&self) -> String;
}
impl IntoUrl for v1_0_0::Url {
fn make_url(&self) -> String {
todo!()
}
}
impl IntoUrl for v2_0_0::Url {
fn make_url(&self) -> String {
todo!()
}
}
impl IntoUrl for v2_1_0::Url {
fn make_url(&self) -> String {
match self {
v2_1_0::Url::String(x) => x.clone(),
v2_1_0::Url::UrlClass(x) => {
match x {
v2_1_0::UrlClass { raw: Some(x), .. } => x.clone(),
v2_1_0::UrlClass {
raw: None,
hash, host, path, port, protocol, query, variable
} => build_url(
&protocol.unwrap_or("http".into()),
&host.map(|x| x.make_host()).unwrap_or("localhost".into()),
&path.map(|x| x.make_path().as_str()),
&query.map(|x| x.make_query().as_str()),
&hash.map(|x| x.as_str())
)
}
},
}
}
}
fn build_url(
proto: &str,
host: &str,
path: &Option<&str>,
query: &Option<&str>,
hash: &Option<&str>
) -> String {
let mut url = format!("{}://{}", proto, host);
if let Some(p) = path {
url.push('/');
url.push_str(p);
}
if let Some(q) = query {
url.push('?');
url.push_str(&q);
}
if let Some(h) = hash {
url.push('#');
url.push_str(&h);
}
url
}

View file

@ -1,138 +0,0 @@
use crate::{requestor::{TestResultTree, TestResultNode, TestResultCase}, model::collector::{RequestTree, RequestNode}};
// TODO maybe make a generic trait Displayable and make just one recursive function?
pub async fn show_results(tree: TestResultTree, verbose: bool, pretty: bool) {
show_results_r(tree, verbose, pretty, 0).await
}
#[async_recursion::async_recursion]
pub async fn show_results_r(tree: TestResultTree, verbose: bool, pretty: bool, depth: usize) {
let indent_skip = "".repeat(depth);
let indent_node = match depth {
0 => "".into(),
1 => "".into(),
x => "".repeat(x - 1) + "",
};
match tree.result {
TestResultNode::Leaf(rx) => {
let res = rx.await.unwrap();
let method = res.request.method().as_str();
let url = res.request.url().as_str();
match res.result {
TestResultCase::Skip => {
println!("{} ? --- >> {} {}", indent_node, method, url);
if verbose {
println!("{} [skipped]", indent_skip);
println!("{}", indent_skip);
}
},
TestResultCase::Success(response) => {
let status_code = response.status().as_u16();
let marker = if status_code < 400 { '✓' } else { '×' };
println!("{} {} {} >> {} {}", indent_node, marker, status_code, method, url);
if verbose {
let body = process_json_body(response.text().await.unwrap(), pretty);
println!("{} {}", indent_skip, body.replace("\n", &format!("\n{} ", indent_skip)));
println!("{}", indent_skip);
}
},
TestResultCase::Failure(err) => {
println!("{} ! ERROR >> {} {}", indent_node, method, url);
if verbose {
println!("{} {}", indent_skip, err);
println!("{}", indent_skip);
}
}
}
},
TestResultNode::Branch(results) => {
println!("{}─┐ {}", indent_node, tree.name);
if verbose {
if let Some(descr) = tree.description {
println!("{}{}", indent_skip, descr);
}
println!("{}", indent_skip);
}
for res in results {
show_results_r(res, verbose, pretty, depth + 1).await;
}
println!("{}", indent_skip);
},
}
}
pub fn show_requests(tree: RequestTree, verbose: bool, pretty: bool) {
show_requests_r(tree, verbose, pretty, 0);
}
pub fn show_requests_r(tree: RequestTree, verbose: bool, pretty: bool, depth: usize) {
let indent_skip = "".repeat(depth);
let indent_node = match depth {
0 => "".into(),
1 => "".into(),
x => "".repeat(x - 1) + "",
};
match tree.request {
RequestNode::Leaf(request) => {
let method = request.method().as_str();
let url = request.url().as_str();
println!("{} * {} {}", indent_node, method, url);
if verbose {
let headers = request.headers()
.iter()
.map(|(k, v)| format!("{}:{}", k.as_str(), std::str::from_utf8(v.as_bytes()).unwrap()))
.collect::<Vec<String>>();
if headers.len() > 0 {
if pretty {
println!("{} [", indent_skip);
for h in headers {
println!("{} {}", indent_skip, h);
}
println!("{} ]", indent_skip);
} else {
println!("{} [ {} ]", indent_skip, headers.join(", "));
}
}
if let Some(body) = request.body() {
if let Some(bytes) = body.as_bytes() {
let txt = process_json_body(std::str::from_utf8(bytes).unwrap().to_string(), pretty);
println!("{} {}", indent_skip, txt.replace("\n", &format!("\n{} ", indent_skip)));
} else {
println!("{} << streaming body >>", indent_skip);
}
}
println!("{}", indent_skip);
}
},
RequestNode::Branch(requests) => {
println!("{}─┐ {}", indent_node, tree.name);
if verbose {
if let Some(descr) = tree.description {
println!("{}{}", indent_skip, descr);
}
println!("{}", indent_skip);
}
for req in requests {
show_requests_r(req, verbose, pretty, depth + 1);
}
println!("{}", indent_skip);
},
}
}
fn process_json_body(txt: String, pretty: bool) -> String {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) {
if pretty {
if let Ok(t) = serde_json::to_string_pretty(&v) {
return t;
}
} else {
if let Ok(t) = serde_json::to_string(&v) { // try to minify it anyway
return t;
}
}
}
txt
}

View file

@ -1,64 +0,0 @@
use crate::model::collector::{RequestTree, RequestNode};
use tokio::sync::oneshot;
#[derive(Debug)]
pub enum TestResultNode {
Leaf(oneshot::Receiver<TestResultHolder>),
Branch(Vec<TestResultTree>),
}
#[derive(Debug)]
pub struct TestResultTree {
pub name: String,
pub description: Option<String>,
pub result: TestResultNode,
}
#[derive(Debug)]
pub struct TestResultHolder {
pub request: reqwest::Request,
pub result: TestResultCase,
}
#[derive(Debug)]
pub enum TestResultCase {
Skip,
Success(reqwest::Response),
Failure(reqwest::Error),
}
#[async_recursion::async_recursion]
pub async fn send_requests(tree: RequestTree, client: Option<reqwest::Client>, dry_run: bool) -> TestResultTree {
let result : TestResultNode;
match tree.request {
RequestNode::Leaf(request) => {
let (tx, rx) = oneshot::channel();
let c = client.unwrap_or(
reqwest::Client::builder()
.user_agent(crate::APP_USER_AGENT)
.build()
.unwrap()
);
tokio::spawn(async move {
let res = if dry_run { TestResultCase::Skip } else {
match c.execute(request.try_clone().unwrap()).await {
Ok(res) => TestResultCase::Success(res),
Err(e) => TestResultCase::Failure(e),
}
};
tx.send(TestResultHolder { request, result: res }).unwrap();
});
result = TestResultNode::Leaf(rx);
},
RequestNode::Branch(requests) => {
let mut out = Vec::new();
for req in requests {
out.push(send_requests(req, client.clone(), dry_run).await);
}
result = TestResultNode::Branch(out);
}
}
TestResultTree { name: tree.name, description: tree.description, result }
}