feat: starting from scratch
lets start over: simple api testing tool configured with toml files
This commit is contained in:
parent
d2cb41f755
commit
fe7bc43c57
15 changed files with 2008 additions and 1059 deletions
1694
Cargo.lock
generated
Normal file
1694
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
18
Cargo.toml
18
Cargo.toml
|
@ -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
182
README.md
|
@ -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" }
|
||||
```
|
||||
|
|
234
src/main.rs
234
src/main.rs
|
@ -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
192
src/model.rs
Normal 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>,
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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("."),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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("&")
|
||||
}
|
||||
}
|
|
@ -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!()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
138
src/printer.rs
138
src/printer.rs
|
@ -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
|
||||
}
|
|
@ -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 }
|
||||
}
|
Loading…
Reference in a new issue