1mod shortcut;
8
9use base64::{Engine, engine::general_purpose::STANDARD};
10use serde_json::{Map, Value};
11use std::fmt;
12
13pub use shortcut::expand_headers;
14
15#[derive(Debug)]
17pub enum Error {
18 Parse(String),
19 Request(String),
20 Url(String),
21}
22
23impl fmt::Display for Error {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 match self {
26 Error::Parse(msg) => write!(f, "parse error: {msg}"),
27 Error::Request(msg) => write!(f, "request error: {msg}"),
28 Error::Url(msg) => write!(f, "URL error: {msg}"),
29 }
30 }
31}
32
33impl std::error::Error for Error {}
34
35pub type Result<T> = std::result::Result<T, Error>;
36
37pub struct Request {
39 pub method: String,
40 pub url: String,
41 pub headers: Map<String, Value>,
42 pub body: Option<Value>,
43}
44
45pub struct UrlParts {
47 pub scheme: String,
48 pub host: String,
49 pub port: String,
50 pub path: String,
51 pub query: String,
52 pub fragment: String,
53}
54
55pub struct Status {
57 pub line: String,
58 pub version: String,
59 pub code: u16,
60 pub text: String,
61}
62
63pub struct Response {
65 pub status: Status,
66 pub headers_raw: String,
67 pub headers: Map<String, Value>,
68 pub body: Vec<u8>,
69}
70
71pub fn parse(s: &str) -> Result<Value> {
73 serde_json::from_str(s).or_else(|_| {
74 serde_yml::from_str(s)
75 .map_err(|e| Error::Parse(format!("invalid JSON or YAML: {e}")))
76 })
77}
78
79pub fn parse_request(val: &Value) -> Result<Request> {
81 let obj = val
82 .as_object()
83 .ok_or_else(|| Error::Request("request must be a JSON/YAML object".into()))?;
84
85 let mut method = None;
86 let mut url = None;
87 let mut headers = None;
88 let mut body = None;
89 let mut query = None;
90
91 for (key, v) in obj {
92 if let Some(m) = resolve_method(key) {
93 method = Some(m.to_string());
94 url = Some(
95 v.as_str()
96 .ok_or_else(|| Error::Request(format!("URL for method '{key}' must be a string")))?
97 .to_string(),
98 );
99 } else {
100 match key.to_lowercase().as_str() {
101 "h" | "headers" => headers = Some(v.clone()),
102 "b" | "body" => body = Some(v.clone()),
103 "q" | "query" => query = Some(v.clone()),
104 _ => {}
105 }
106 }
107 }
108
109 let mut header_map = headers
110 .and_then(|v| v.as_object().cloned())
111 .unwrap_or_default();
112 expand_headers(&mut header_map);
113
114 let mut final_url = url
115 .ok_or_else(|| Error::Request("no URL found".into()))?;
116
117 if let Some(q) = query {
118 let q_obj = q
119 .as_object()
120 .ok_or_else(|| Error::Request("q must be an object".into()))?;
121 if !q_obj.is_empty() {
122 let query_string = build_query_string(q_obj);
123 if final_url.contains('?') {
124 final_url.push('&');
125 } else {
126 final_url.push('?');
127 }
128 final_url.push_str(&query_string);
129 }
130 }
131
132 Ok(Request {
133 method: method
134 .ok_or_else(|| Error::Request("no HTTP method found".into()))?,
135 url: final_url,
136 headers: header_map,
137 body,
138 })
139}
140
141pub fn parse_url(url_str: &str) -> Result<UrlParts> {
143 let parsed = url::Url::parse(url_str)
144 .map_err(|e| Error::Url(format!("{e}")))?;
145 Ok(UrlParts {
146 scheme: parsed.scheme().to_string(),
147 host: parsed.host_str().unwrap_or("").to_string(),
148 port: parsed.port().map(|p| p.to_string()).unwrap_or_default(),
149 path: parsed.path().trim_start_matches('/').to_string(),
150 query: parsed.query().unwrap_or("").to_string(),
151 fragment: parsed.fragment().unwrap_or("").to_string(),
152 })
153}
154
155pub fn encode_body(bytes: &[u8]) -> Value {
157 if let Ok(json_val) = serde_json::from_slice::<Value>(bytes) {
158 return json_val;
159 }
160 if let Ok(s) = std::str::from_utf8(bytes) {
161 return Value::String(s.to_string());
162 }
163 Value::String(STANDARD.encode(bytes))
164}
165
166pub fn status_inline(status: &Status) -> Value {
168 let mut m = Map::new();
169 m.insert("v".to_string(), Value::String(status.version.clone()));
170 m.insert("c".to_string(), Value::Number(status.code.into()));
171 m.insert("t".to_string(), Value::String(status.text.clone()));
172 Value::Object(m)
173}
174
175pub fn format_response(resp: &Response) -> Value {
177 let mut map = Map::new();
178 map.insert("s".to_string(), status_inline(&resp.status));
179 map.insert("h".to_string(), Value::Object(resp.headers.clone()));
180 map.insert("b".to_string(), encode_body(&resp.body));
181 Value::Object(map)
182}
183
184pub fn headers_to_raw(headers: &Map<String, Value>) -> String {
186 let mut raw = String::new();
187 for (k, v) in headers {
188 if let Some(s) = v.as_str() {
189 raw.push_str(&format!("{k}: {s}\r\n"));
190 }
191 }
192 raw
193}
194
195fn encode_query_component(s: &str) -> String {
196 url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
197}
198
199fn value_to_string(v: &Value) -> String {
200 match v {
201 Value::String(s) => s.clone(),
202 Value::Number(n) => n.to_string(),
203 Value::Bool(b) => b.to_string(),
204 Value::Null => String::new(),
205 _ => v.to_string(),
206 }
207}
208
209fn build_query_string(obj: &Map<String, Value>) -> String {
210 let mut pairs = Vec::new();
211 for (k, v) in obj {
212 let key = encode_query_component(k);
213 match v {
214 Value::Array(arr) => {
215 for item in arr {
216 pairs.push(format!("{}={}", key, encode_query_component(&value_to_string(item))));
217 }
218 }
219 _ => {
220 pairs.push(format!("{}={}", key, encode_query_component(&value_to_string(v))));
221 }
222 }
223 }
224 pairs.join("&")
225}
226
227fn resolve_method(key: &str) -> Option<&'static str> {
228 match key.to_lowercase().as_str() {
229 "get" | "g" => Some("GET"),
230 "post" | "p" => Some("POST"),
231 "put" => Some("PUT"),
232 "delete" | "d" => Some("DELETE"),
233 "patch" => Some("PATCH"),
234 "head" => Some("HEAD"),
235 "options" => Some("OPTIONS"),
236 "trace" => Some("TRACE"),
237 _ => None,
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
248 fn parse_json() {
249 let val = parse(r#"{"g": "https://example.com"}"#).unwrap();
250 assert_eq!(val["g"], "https://example.com");
251 }
252
253 #[test]
254 fn parse_yaml_block() {
255 let val = parse("g: https://example.com\nh:\n Accept: j!\n").unwrap();
256 assert_eq!(val["g"], "https://example.com");
257 assert_eq!(val["h"]["Accept"], "j!");
258 }
259
260 #[test]
261 fn parse_yaml_flow() {
262 let val = parse("{g: https://example.com, h: {Accept: j!}}").unwrap();
263 assert_eq!(val["g"], "https://example.com");
264 }
265
266 #[test]
267 fn parse_invalid() {
268 assert!(parse("{{invalid}}").is_err());
269 }
270
271 #[test]
274 fn parse_request_get() {
275 let val = parse("{g: https://example.com}").unwrap();
276 let req = parse_request(&val).unwrap();
277 assert_eq!(req.method, "GET");
278 assert_eq!(req.url, "https://example.com");
279 assert!(req.body.is_none());
280 }
281
282 #[test]
283 fn parse_request_post_with_body() {
284 let val = parse(r#"{"p": "https://example.com", "b": {"key": "val"}}"#).unwrap();
285 let req = parse_request(&val).unwrap();
286 assert_eq!(req.method, "POST");
287 assert_eq!(req.body.unwrap()["key"], "val");
288 }
289
290 #[test]
291 fn parse_request_method_case_insensitive() {
292 let val = parse(r#"{"GET": "https://example.com"}"#).unwrap();
293 let req = parse_request(&val).unwrap();
294 assert_eq!(req.method, "GET");
295 }
296
297 #[test]
298 fn parse_request_no_method() {
299 let val = parse(r#"{"h": {"Accept": "j!"}}"#).unwrap();
300 assert!(parse_request(&val).is_err());
301 }
302
303 #[test]
304 fn parse_request_not_object() {
305 let val = Value::String("not an object".into());
306 assert!(parse_request(&val).is_err());
307 }
308
309 #[test]
310 fn parse_request_all_methods() {
311 for (short, full) in &[
312 ("g", "GET"),
313 ("p", "POST"),
314 ("d", "DELETE"),
315 ("put", "PUT"),
316 ("patch", "PATCH"),
317 ("head", "HEAD"),
318 ("options", "OPTIONS"),
319 ("trace", "TRACE"),
320 ] {
321 let val = parse(&format!("{{{short}: https://example.com}}")).unwrap();
322 let req = parse_request(&val).unwrap();
323 assert_eq!(req.method, *full);
324 }
325 }
326
327 #[test]
330 fn header_bearer_bare_token() {
331 let val = parse("{g: https://example.com, h: {a!: my-token}}").unwrap();
332 let req = parse_request(&val).unwrap();
333 assert_eq!(req.headers["Authorization"], "Bearer my-token");
334 }
335
336 #[test]
337 fn header_bearer_explicit() {
338 let val = parse("{g: https://example.com, h: {a!: bearer!tok}}").unwrap();
339 let req = parse_request(&val).unwrap();
340 assert_eq!(req.headers["Authorization"], "Bearer tok");
341 }
342
343 #[test]
344 fn header_basic_array() {
345 let val = parse(r#"{"g": "https://example.com", "h": {"a!": ["user", "pass"]}}"#).unwrap();
346 let req = parse_request(&val).unwrap();
347 assert_eq!(req.headers["Authorization"], "Basic dXNlcjpwYXNz");
348 }
349
350 #[test]
351 fn header_basic_explicit() {
352 let val = parse("{g: https://example.com, h: {a!: basic!user:pass}}").unwrap();
353 let req = parse_request(&val).unwrap();
354 assert_eq!(req.headers["Authorization"], "Basic dXNlcjpwYXNz");
355 }
356
357 #[test]
358 fn header_auth_scheme_passthrough() {
359 let val = parse("{g: https://example.com, h: {a!: Digest abc123}}").unwrap();
360 let req = parse_request(&val).unwrap();
361 assert_eq!(req.headers["Authorization"], "Digest abc123");
362 }
363
364 #[test]
365 fn header_content_type_shortcut() {
366 let val = parse("{g: https://example.com, h: {c!: f!}}").unwrap();
367 let req = parse_request(&val).unwrap();
368 assert_eq!(
369 req.headers["Content-Type"],
370 "application/x-www-form-urlencoded"
371 );
372 }
373
374 #[test]
375 fn header_value_shortcuts() {
376 let cases = vec![
377 ("j!", "application/json"),
378 ("json!", "application/json"),
379 ("f!", "application/x-www-form-urlencoded"),
380 ("m!", "multipart/form-data"),
381 ("h!", "text/html"),
382 ("t!", "text/plain"),
383 ("x!", "application/xml"),
384 ];
385 for (shortcut, expected) in cases {
386 let val =
387 parse(&format!("{{g: https://example.com, h: {{Accept: {shortcut}}}}}")).unwrap();
388 let req = parse_request(&val).unwrap();
389 assert_eq!(req.headers["Accept"], expected, "shortcut {shortcut}");
390 }
391 }
392
393 #[test]
394 fn header_prefix_shortcuts() {
395 let cases = vec![
396 ("a!/json", "application/json"),
397 ("t!/csv", "text/csv"),
398 ("i!/png", "image/png"),
399 ];
400 for (shortcut, expected) in cases {
401 let val =
402 parse(&format!("{{g: https://example.com, h: {{Accept: {shortcut}}}}}")).unwrap();
403 let req = parse_request(&val).unwrap();
404 assert_eq!(req.headers["Accept"], expected, "prefix {shortcut}");
405 }
406 }
407
408 #[test]
411 fn query_basic() {
412 let val = parse("{g: https://example.com/search, q: {term: foo, limit: 10}}").unwrap();
413 let req = parse_request(&val).unwrap();
414 assert_eq!(req.url, "https://example.com/search?term=foo&limit=10");
415 }
416
417 #[test]
418 fn query_merge_existing() {
419 let val = parse("{g: https://example.com/search?x=1, q: {y: 2}}").unwrap();
420 let req = parse_request(&val).unwrap();
421 assert_eq!(req.url, "https://example.com/search?x=1&y=2");
422 }
423
424 #[test]
425 fn query_array_repeated_keys() {
426 let val = parse(r#"{"g": "https://example.com/search", "q": {"tags": ["a", "b"]}}"#).unwrap();
427 let req = parse_request(&val).unwrap();
428 assert_eq!(req.url, "https://example.com/search?tags=a&tags=b");
429 }
430
431 #[test]
432 fn query_url_encoding() {
433 let val = parse(r#"{"g": "https://example.com/search", "q": {"q": "hello world"}}"#).unwrap();
434 let req = parse_request(&val).unwrap();
435 assert_eq!(req.url, "https://example.com/search?q=hello+world");
436 }
437
438 #[test]
439 fn query_absent_noop() {
440 let val = parse("{g: https://example.com}").unwrap();
441 let req = parse_request(&val).unwrap();
442 assert_eq!(req.url, "https://example.com");
443 }
444
445 #[test]
446 fn query_empty_noop() {
447 let val = parse(r#"{"g": "https://example.com", "q": {}}"#).unwrap();
448 let req = parse_request(&val).unwrap();
449 assert_eq!(req.url, "https://example.com");
450 }
451
452 #[test]
453 fn query_boolean_value() {
454 let val = parse(r#"{"g": "https://example.com", "q": {"active": true}}"#).unwrap();
455 let req = parse_request(&val).unwrap();
456 assert_eq!(req.url, "https://example.com?active=true");
457 }
458
459 #[test]
462 fn parse_url_parts() {
463 let parts = parse_url("https://example.com:8080/api/items?q=test#section").unwrap();
464 assert_eq!(parts.scheme, "https");
465 assert_eq!(parts.host, "example.com");
466 assert_eq!(parts.port, "8080");
467 assert_eq!(parts.path, "api/items");
468 assert_eq!(parts.query, "q=test");
469 assert_eq!(parts.fragment, "section");
470 }
471
472 #[test]
473 fn parse_url_defaults() {
474 let parts = parse_url("https://example.com/path").unwrap();
475 assert_eq!(parts.port, "");
476 assert_eq!(parts.query, "");
477 assert_eq!(parts.fragment, "");
478 }
479
480 #[test]
481 fn parse_url_invalid() {
482 assert!(parse_url("not a url").is_err());
483 }
484
485 #[test]
488 fn encode_body_json() {
489 let body = encode_body(b"[1, 2, 3]");
490 assert!(body.is_array());
491 assert_eq!(body[0], 1);
492 }
493
494 #[test]
495 fn encode_body_json_object() {
496 let body = encode_body(br#"{"key": "val"}"#);
497 assert!(body.is_object());
498 assert_eq!(body["key"], "val");
499 }
500
501 #[test]
502 fn encode_body_utf8() {
503 let body = encode_body(b"hello world");
504 assert_eq!(body, "hello world");
505 }
506
507 #[test]
508 fn encode_body_binary() {
509 let bytes = vec![0xff, 0xfe, 0x00, 0x01];
510 let body = encode_body(&bytes);
511 assert!(body.is_string());
512 let s = body.as_str().unwrap();
513 assert_eq!(
514 base64::engine::general_purpose::STANDARD
515 .decode(s)
516 .unwrap(),
517 bytes
518 );
519 }
520
521 #[test]
524 fn status_inline_format() {
525 let status = Status {
526 line: "HTTP/1.1 200 OK".to_string(),
527 version: "HTTP/1.1".to_string(),
528 code: 200,
529 text: "OK".to_string(),
530 };
531 let val = status_inline(&status);
532 assert_eq!(val["v"], "HTTP/1.1");
533 assert_eq!(val["c"], 200);
534 assert_eq!(val["t"], "OK");
535 }
536
537 #[test]
540 fn format_response_structure() {
541 let resp = Response {
542 status: Status {
543 line: "HTTP/1.1 200 OK".to_string(),
544 version: "HTTP/1.1".to_string(),
545 code: 200,
546 text: "OK".to_string(),
547 },
548 headers_raw: "content-type: application/json\r\n".to_string(),
549 headers: {
550 let mut m = Map::new();
551 m.insert(
552 "content-type".to_string(),
553 Value::String("application/json".to_string()),
554 );
555 m
556 },
557 body: br#"{"id": 1}"#.to_vec(),
558 };
559 let val = format_response(&resp);
560 assert_eq!(val["s"]["c"], 200);
561 assert_eq!(val["h"]["content-type"], "application/json");
562 assert_eq!(val["b"]["id"], 1);
563 }
564
565 #[test]
568 fn headers_to_raw_format() {
569 let mut headers = Map::new();
570 headers.insert(
571 "Accept".to_string(),
572 Value::String("application/json".to_string()),
573 );
574 headers.insert(
575 "Host".to_string(),
576 Value::String("example.com".to_string()),
577 );
578 let raw = headers_to_raw(&headers);
579 assert!(raw.contains("Accept: application/json\r\n"));
580 assert!(raw.contains("Host: example.com\r\n"));
581 }
582}