Skip to main content

yttp/
lib.rs

1//! # yttp - "Better HTTP"
2//!
3//! A JSON/YAML façade for HTTP requests and responses.
4//! Provides header shortcuts, smart auth, content-type-driven body encoding,
5//! and structured response formatting.
6
7mod 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/// Error type for yttp operations.
16#[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
37/// Parsed HTTP request, ready to send.
38pub struct Request {
39    pub method: String,
40    pub url: String,
41    pub headers: Map<String, Value>,
42    pub body: Option<Value>,
43}
44
45/// URL components.
46pub 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
55/// Structured status.
56pub struct Status {
57    pub line: String,
58    pub version: String,
59    pub code: u16,
60    pub text: String,
61}
62
63/// Structured response.
64pub struct Response {
65    pub status: Status,
66    pub headers_raw: String,
67    pub headers: Map<String, Value>,
68    pub body: Vec<u8>,
69}
70
71/// Parse a JSON/YAML string into a serde_json::Value.
72pub 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
79/// Parse a request from a JSON/YAML value, expanding header shortcuts.
80pub 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
141/// Parse URL into components.
142pub 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
155/// Encode response body for structured output: JSON → value, UTF-8 → string, binary → base64.
156pub 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
166/// Format status as an inline object {v, c, t}.
167pub 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
175/// Format a full response as a structured value (s!, h, b).
176pub 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
184/// Build request headers as raw HTTP string.
185pub 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    // --- parse ---
246
247    #[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    // --- parse_request ---
272
273    #[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    // --- header shortcuts ---
328
329    #[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    // --- query params ---
409
410    #[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    // --- parse_url ---
460
461    #[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    // --- encode_body ---
486
487    #[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    // --- status_inline ---
522
523    #[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    // --- format_response ---
538
539    #[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    // --- headers_to_raw ---
566
567    #[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}