diff options
| author | Filip Wandzio <contact@philw.dev> | 2025-12-28 00:45:12 +0100 |
|---|---|---|
| committer | Filip Wandzio <contact@philw.dev> | 2025-12-28 00:45:12 +0100 |
| commit | 7995282f65183b0a615c224a3ea13eeb10a1e828 (patch) | |
| tree | 84705b645f3cc799b8d6cf8af55b67dbc045e82a | |
| download | pdat-master.tar.gz pdat-master.zip | |
Implement basic way of testing various http methods via simple cli interface. Also write basic config file kind of parser.
Diffstat (limited to '')
| -rw-r--r-- | .gitignore | 6 | ||||
| -rw-r--r-- | Cargo.toml | 4 | ||||
| -rw-r--r-- | Dockerfile | 24 | ||||
| -rw-r--r-- | apitool.toml | 8 | ||||
| -rw-r--r-- | config/Cargo.toml | 8 | ||||
| -rw-r--r-- | config/src/lib.rs | 94 | ||||
| -rw-r--r-- | main/Cargo.toml | 15 | ||||
| -rw-r--r-- | main/src/main.rs | 185 |
8 files changed, 344 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc50474 --- /dev/null +++ b/.gitignore | |||
| @@ -0,0 +1,6 @@ | |||
| 1 | debug/ | ||
| 2 | target/ | ||
| 3 | Cargo.lock | ||
| 4 | **/*.rs.bk | ||
| 5 | *.pdb | ||
| 6 | rust-project.json | ||
diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..98154e1 --- /dev/null +++ b/Cargo.toml | |||
| @@ -0,0 +1,4 @@ | |||
| 1 | [workspace] | ||
| 2 | members = ["main", "config"] | ||
| 3 | |||
| 4 | resolver = "3" | ||
diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d88ceea --- /dev/null +++ b/Dockerfile | |||
| @@ -0,0 +1,24 @@ | |||
| 1 | FROM lukemathwalker/cargo-chef:latest AS chef | ||
| 2 | WORKDIR /app | ||
| 3 | |||
| 4 | FROM chef AS planner | ||
| 5 | COPY . . | ||
| 6 | RUN cargo chef prepare --recipe-path recipe.json | ||
| 7 | |||
| 8 | FROM chef AS builder | ||
| 9 | COPY --from=planner /app/recipe.json recipe.json | ||
| 10 | RUN cargo chef cook --release --recipe-path recipe.json | ||
| 11 | COPY . . | ||
| 12 | RUN cargo build --release --bin macmeeting_back | ||
| 13 | |||
| 14 | FROM debian:bookworm-slim AS runtime | ||
| 15 | WORKDIR /app | ||
| 16 | |||
| 17 | RUN apt-get update && apt-get install -y \ | ||
| 18 | libssl3 \ | ||
| 19 | libpq-dev \ | ||
| 20 | && apt-get clean && rm -rf /var/lib/apt/lists/* | ||
| 21 | |||
| 22 | COPY --from=builder /app/target/release/macmeeting_back /usr/local/bin | ||
| 23 | |||
| 24 | ENTRYPOINT ["/usr/local/bin/macmeeting_back"] | ||
diff --git a/apitool.toml b/apitool.toml new file mode 100644 index 0000000..7086f4c --- /dev/null +++ b/apitool.toml | |||
| @@ -0,0 +1,8 @@ | |||
| 1 | |||
| 2 | [default] | ||
| 3 | headers = [ | ||
| 4 | "Authorization: Bearer YOUR_TOKEN", | ||
| 5 | "Content-Type: application/json" | ||
| 6 | ] | ||
| 7 | |||
| 8 | indent = 4 | ||
diff --git a/config/Cargo.toml b/config/Cargo.toml new file mode 100644 index 0000000..b7a0be1 --- /dev/null +++ b/config/Cargo.toml | |||
| @@ -0,0 +1,8 @@ | |||
| 1 | [package] | ||
| 2 | name = "config" | ||
| 3 | version = "0.1.0" | ||
| 4 | edition = "2024" | ||
| 5 | |||
| 6 | [dependencies] | ||
| 7 | toml = "0.7" | ||
| 8 | serde = { version = "1.0", features = ["derive"] } | ||
diff --git a/config/src/lib.rs b/config/src/lib.rs new file mode 100644 index 0000000..d83b615 --- /dev/null +++ b/config/src/lib.rs | |||
| @@ -0,0 +1,94 @@ | |||
| 1 | use serde::Deserialize; | ||
| 2 | use std::fs; | ||
| 3 | use std::path::Path; | ||
| 4 | |||
| 5 | #[derive(Debug, Deserialize)] | ||
| 6 | pub struct DefaultConfig { | ||
| 7 | pub headers: Option<Vec<String>>, | ||
| 8 | pub indent: Option<usize>, | ||
| 9 | } | ||
| 10 | |||
| 11 | #[derive(Debug, Deserialize)] | ||
| 12 | pub struct Config { | ||
| 13 | pub default: Option<DefaultConfig>, | ||
| 14 | } | ||
| 15 | |||
| 16 | impl Config { | ||
| 17 | pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> { | ||
| 18 | let content = fs::read_to_string(path)?; | ||
| 19 | let config: Config = toml::from_str(&content)?; | ||
| 20 | Ok(config) | ||
| 21 | } | ||
| 22 | |||
| 23 | pub fn headers(&self) -> Vec<String> { | ||
| 24 | self.default | ||
| 25 | .as_ref() | ||
| 26 | .and_then(|d| d.headers.clone()) | ||
| 27 | .unwrap_or_default() | ||
| 28 | } | ||
| 29 | |||
| 30 | pub fn indent(&self) -> usize { | ||
| 31 | self.default.as_ref().and_then(|d| d.indent).unwrap_or(2) | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | #[cfg(test)] | ||
| 36 | mod tests { | ||
| 37 | use super::*; | ||
| 38 | use std::env; | ||
| 39 | use std::fs::write; | ||
| 40 | |||
| 41 | #[test] | ||
| 42 | fn test_from_file_with_realistic_headers() { | ||
| 43 | let toml_content = r#" | ||
| 44 | [default] | ||
| 45 | headers = [ | ||
| 46 | "Authorization: Bearer YOUR_TOKEN", | ||
| 47 | "Content-Type: application/json" | ||
| 48 | ] | ||
| 49 | indent = 4 | ||
| 50 | "#; | ||
| 51 | |||
| 52 | let tmp_path = std::env::temp_dir().join("test_real_config.toml"); | ||
| 53 | std::fs::write(&tmp_path, toml_content).expect("Failed to write TOML file"); | ||
| 54 | |||
| 55 | let config = Config::from_file(&tmp_path).expect("Failed to read config from file"); | ||
| 56 | |||
| 57 | assert_eq!( | ||
| 58 | config.headers(), | ||
| 59 | vec![ | ||
| 60 | "Authorization: Bearer YOUR_TOKEN", | ||
| 61 | "Content-Type: application/json" | ||
| 62 | ] | ||
| 63 | ); | ||
| 64 | assert_eq!(config.indent(), 4); | ||
| 65 | } | ||
| 66 | #[test] | ||
| 67 | fn test_missing_values_defaults() { | ||
| 68 | let toml_content = r#""#; | ||
| 69 | |||
| 70 | let config: Config = toml::from_str(toml_content).expect("Failed to parse empty TOML"); | ||
| 71 | |||
| 72 | assert_eq!(config.headers(), Vec::<String>::new()); | ||
| 73 | assert_eq!(config.indent(), 2); | ||
| 74 | } | ||
| 75 | |||
| 76 | #[test] | ||
| 77 | fn test_from_file_function() { | ||
| 78 | let toml_content = r#" | ||
| 79 | [default] | ||
| 80 | headers = ["User", "Email"] | ||
| 81 | indent = 3 | ||
| 82 | "#; | ||
| 83 | |||
| 84 | let tmp_dir = env::temp_dir(); | ||
| 85 | let file_path = tmp_dir.join("test_config.toml"); | ||
| 86 | |||
| 87 | write(&file_path, toml_content).expect("Failed to write temp TOML"); | ||
| 88 | |||
| 89 | let config = Config::from_file(&file_path).expect("Failed to read config"); | ||
| 90 | |||
| 91 | assert_eq!(config.headers(), vec!["User", "Email"]); | ||
| 92 | assert_eq!(config.indent(), 3); | ||
| 93 | } | ||
| 94 | } | ||
diff --git a/main/Cargo.toml b/main/Cargo.toml new file mode 100644 index 0000000..e91ddbf --- /dev/null +++ b/main/Cargo.toml | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | [package] | ||
| 2 | name = "pdat" | ||
| 3 | version = "0.1.0" | ||
| 4 | edition = "2024" | ||
| 5 | |||
| 6 | [dependencies] | ||
| 7 | clap = {version = "4.5.48", features = ["derive"] } | ||
| 8 | colored = "3.0.0" | ||
| 9 | reqwest = {version = "0.12.23", features = ["json"] } | ||
| 10 | serde_json = {version = "1.0.145"} | ||
| 11 | tokio = {version = "1.47.1", features = ["full"]} | ||
| 12 | toml = "0.9.7" | ||
| 13 | |||
| 14 | |||
| 15 | config = { path = "../config" } | ||
diff --git a/main/src/main.rs b/main/src/main.rs new file mode 100644 index 0000000..0604673 --- /dev/null +++ b/main/src/main.rs | |||
| @@ -0,0 +1,185 @@ | |||
| 1 | use clap::Parser; | ||
| 2 | use colored::*; | ||
| 3 | use config::Config; | ||
| 4 | use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; | ||
| 5 | use serde_json::Value; | ||
| 6 | use std::process; | ||
| 7 | |||
| 8 | #[derive(Parser)] | ||
| 9 | #[command(author, version, about)] | ||
| 10 | struct Args { | ||
| 11 | method: String, | ||
| 12 | url: String, | ||
| 13 | #[arg(short = 'H', long)] | ||
| 14 | header: Vec<String>, | ||
| 15 | #[arg(short = 'd', long)] | ||
| 16 | data: Option<String>, | ||
| 17 | #[arg(short = 'c', long)] | ||
| 18 | config: Option<String>, | ||
| 19 | #[arg(long)] | ||
| 20 | indent: Option<usize>, | ||
| 21 | } | ||
| 22 | |||
| 23 | fn print_colored_json(value: &Value, indent_level: usize, indent_spaces: usize) { | ||
| 24 | let indent = " ".repeat(indent_spaces * indent_level); | ||
| 25 | |||
| 26 | match value { | ||
| 27 | Value::Object(map) => { | ||
| 28 | println!("{}{}", indent, "{".bright_blue()); | ||
| 29 | for (key, val) in map { | ||
| 30 | print!( | ||
| 31 | "{}{}: ", | ||
| 32 | " ".repeat(indent_spaces * (indent_level + 1)), | ||
| 33 | key.bright_cyan() | ||
| 34 | ); | ||
| 35 | print_colored_json(val, indent_level + 1, indent_spaces); | ||
| 36 | } | ||
| 37 | println!("{}{}", indent, "}".bright_blue()); | ||
| 38 | } | ||
| 39 | Value::Array(arr) => { | ||
| 40 | println!("{}{}", indent, "[".bright_blue()); | ||
| 41 | for val in arr { | ||
| 42 | print_colored_json(val, indent_level + 1, indent_spaces); | ||
| 43 | } | ||
| 44 | println!("{}{}", indent, "]".bright_blue()); | ||
| 45 | } | ||
| 46 | Value::String(s) => println!("{}{}", indent, format!("\"{}\"", s).bright_yellow()), | ||
| 47 | Value::Number(n) => println!("{}{}", indent, n.to_string().bright_magenta()), | ||
| 48 | Value::Bool(b) => println!("{}{}", indent, b.to_string().bright_green()), | ||
| 49 | Value::Null => println!("{}{}", indent, "null".bright_black()), | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | fn colorize_status(status_code: u16) -> ColoredString { | ||
| 54 | match status_code { | ||
| 55 | 100..=199 => status_code.to_string().bright_blue(), | ||
| 56 | 200..=299 => status_code.to_string().green(), | ||
| 57 | 300..=399 => status_code.to_string().cyan(), | ||
| 58 | 400..=499 => status_code.to_string().yellow(), | ||
| 59 | 500..=599 => status_code.to_string().red(), | ||
| 60 | _ => status_code.to_string().normal(), | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | fn build_request( | ||
| 65 | client: &reqwest::Client, | ||
| 66 | method: &str, | ||
| 67 | url: &str, | ||
| 68 | headers: HeaderMap, | ||
| 69 | ) -> Result<reqwest::RequestBuilder, String> { | ||
| 70 | let method = method.to_uppercase(); | ||
| 71 | let builder = match method.as_str() { | ||
| 72 | "GET" => Ok(client.get(url)), | ||
| 73 | "POST" => Ok(client.post(url)), | ||
| 74 | "PUT" => Ok(client.put(url)), | ||
| 75 | "DELETE" => Ok(client.delete(url)), | ||
| 76 | "PATCH" => Ok(client.patch(url)), | ||
| 77 | "OPTIONS" => Ok(client.request(reqwest::Method::OPTIONS, url)), | ||
| 78 | "HEAD" => Ok(client.head(url)), | ||
| 79 | other => Err(format!("Unsupported HTTP method: {}", other)), | ||
| 80 | }?; | ||
| 81 | Ok(builder.headers(headers)) | ||
| 82 | } | ||
| 83 | |||
| 84 | #[tokio::main] | ||
| 85 | async fn main() { | ||
| 86 | let args = Args::parse(); | ||
| 87 | let client = reqwest::Client::new(); | ||
| 88 | |||
| 89 | let config = match args.config.as_ref().map(|p| Config::from_file(p)) { | ||
| 90 | Some(Ok(cfg)) => Some(cfg), | ||
| 91 | Some(Err(e)) => { | ||
| 92 | eprintln!("{}: Failed to load config: {}", "Error".red(), e); | ||
| 93 | process::exit(1); | ||
| 94 | } | ||
| 95 | None => None, | ||
| 96 | }; | ||
| 97 | |||
| 98 | let mut headers = HeaderMap::new(); | ||
| 99 | |||
| 100 | if let Some(cfg) = &config { | ||
| 101 | for header_str in cfg.headers() { | ||
| 102 | match header_str.split_once(':') { | ||
| 103 | Some((key, value)) => { | ||
| 104 | if let (Ok(k), Ok(v)) = ( | ||
| 105 | HeaderName::from_bytes(key.trim().as_bytes()), | ||
| 106 | HeaderValue::from_str(value.trim()), | ||
| 107 | ) { | ||
| 108 | headers.insert(k, v); | ||
| 109 | } | ||
| 110 | } | ||
| 111 | None => eprintln!( | ||
| 112 | "{}: Invalid header format in config: {}", | ||
| 113 | "Warning".yellow(), | ||
| 114 | header_str | ||
| 115 | ), | ||
| 116 | } | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | for header_str in &args.header { | ||
| 121 | match header_str.split_once(':') { | ||
| 122 | Some((key, value)) => { | ||
| 123 | if let (Ok(k), Ok(v)) = ( | ||
| 124 | HeaderName::from_bytes(key.trim().as_bytes()), | ||
| 125 | HeaderValue::from_str(value.trim()), | ||
| 126 | ) { | ||
| 127 | headers.insert(k, v); | ||
| 128 | } | ||
| 129 | } | ||
| 130 | None => eprintln!( | ||
| 131 | "{}: Invalid header format in CLI: {}", | ||
| 132 | "Warning".yellow(), | ||
| 133 | header_str | ||
| 134 | ), | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | let indent_spaces = args | ||
| 139 | .indent | ||
| 140 | .or_else(|| config.as_ref().map(|c| c.indent())) | ||
| 141 | .unwrap_or(2); | ||
| 142 | |||
| 143 | let mut request_builder = match build_request(&client, &args.method, &args.url, headers) { | ||
| 144 | Ok(r) => r, | ||
| 145 | Err(e) => { | ||
| 146 | eprintln!("{}: {}", "Error".red(), e); | ||
| 147 | process::exit(1); | ||
| 148 | } | ||
| 149 | }; | ||
| 150 | |||
| 151 | if let Some(json_data) = args.data { | ||
| 152 | match serde_json::from_str::<Value>(&json_data) { | ||
| 153 | Ok(json_value) => request_builder = request_builder.json(&json_value), | ||
| 154 | Err(e) => { | ||
| 155 | eprintln!("{}: Invalid JSON body: {}", "Error".red(), e); | ||
| 156 | process::exit(1); | ||
| 157 | } | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | let response = match request_builder.send().await { | ||
| 162 | Ok(resp) => resp, | ||
| 163 | Err(e) => { | ||
| 164 | eprintln!("{}: Request failed: {}", "Error".red(), e); | ||
| 165 | process::exit(1); | ||
| 166 | } | ||
| 167 | }; | ||
| 168 | |||
| 169 | let status_code = response.status().as_u16(); | ||
| 170 | println!("Status: {}", colorize_status(status_code)); | ||
| 171 | |||
| 172 | let response_text = match response.text().await { | ||
| 173 | Ok(t) => t, | ||
| 174 | Err(e) => { | ||
| 175 | eprintln!("{}: Failed to read response body: {}", "Error".red(), e); | ||
| 176 | process::exit(1); | ||
| 177 | } | ||
| 178 | }; | ||
| 179 | |||
| 180 | if let Ok(json_value) = serde_json::from_str::<Value>(&response_text) { | ||
| 181 | print_colored_json(&json_value, 0, indent_spaces); | ||
| 182 | } else { | ||
| 183 | println!("{}", response_text); | ||
| 184 | } | ||
| 185 | } | ||
