Skip to main content

tablo_core/
protocol.rs

1//! Reads values back out of rendered responses: bodies, cookies, form data,
2//! and the semantic HTML queries over tables, fields, and filters.
3//!
4//! Re-exported for compatibility by `tablo-test`, whose client drives the
5//! requests these helpers read.
6
7pub mod dom;
8
9pub use dom::{FilterOption, Row, RowActions, field_error, filter_options, row_actions, rows};
10use http_body_util::BodyExt;
11use topcoat::router::Body;
12
13/// One `Cookie` header value from `(name, value)` pairs, or `None` when empty.
14pub fn cookie_header<'a>(cookies: impl IntoIterator<Item = (&'a str, &'a str)>) -> Option<String> {
15    let mut jar = String::new();
16    for (name, value) in cookies {
17        if !jar.is_empty() {
18            jar.push_str("; ");
19        }
20        jar.push_str(name);
21        jar.push('=');
22        jar.push_str(value);
23    }
24    (!jar.is_empty()).then_some(jar)
25}
26
27/// Collect a response body as bytes.
28pub async fn body_bytes(response: http::Response<Body>) -> Vec<u8> {
29    response
30        .into_body()
31        .collect()
32        .await
33        .expect("collect body")
34        .to_bytes()
35        .to_vec()
36}
37
38/// Collect a response body as a lossy UTF-8 string.
39pub async fn body_string(response: http::Response<Body>) -> String {
40    String::from_utf8_lossy(&body_bytes(response).await).into_owned()
41}
42
43/// URL-encode `(key, value)` pairs into an urlencoded form body.
44pub fn form_body(pairs: &[(&str, &str)]) -> String {
45    let mut serializer = form_urlencoded::Serializer::new(String::new());
46    for (key, value) in pairs {
47        serializer.append_pair(key, value);
48    }
49    serializer.finish()
50}
51
52/// Builds a multipart body, one part per entry: `None` is a text part,
53/// `Some("")` the browser's "no file chosen" part, `Some(name)` a chosen file.
54pub fn multipart_body(boundary: &str, parts: &[(&str, Option<&str>, &str)]) -> String {
55    let mut body = String::new();
56    for (name, filename, content) in parts {
57        body.push_str(&format!(
58            "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\""
59        ));
60        if let Some(filename) = filename {
61            body.push_str(&format!("; filename=\"{filename}\""));
62        }
63        body.push_str("\r\n\r\n");
64        body.push_str(content);
65        body.push_str("\r\n");
66    }
67    body.push_str(&format!("--{boundary}--\r\n"));
68    body
69}
70
71/// Reads the `value` of the named `<input>` in rendered HTML, in either
72/// attribute order or quote style.
73pub fn input_value(html: &str, name: &str) -> Option<String> {
74    let double = format!("name=\"{name}\"");
75    let single = format!("name='{name}'");
76    for tag in html.split('<').skip(1) {
77        if !tag.contains(&double) && !tag.contains(&single) {
78            continue;
79        }
80        let attrs = &tag[..tag.find('>')?];
81        for (prefix, term) in [("value=\"", '"'), ("value='", '\'')] {
82            if let Some(start) = attrs.find(prefix) {
83                let rest = &attrs[start + prefix.len()..];
84                if let Some(end) = rest.find(term) {
85                    // Unescape the matching quote entity the encoder may emit.
86                    let raw = &rest[..end];
87                    return Some(raw.replace("&quot;", "\"").replace("&#x27;", "'"));
88                }
89            }
90        }
91    }
92    None
93}
94
95/// Names the session cookie Topcoat's default token store writes.
96pub const SESSION_COOKIE: &str = "__Host-session";
97
98/// The `(name, value)` pairs a response's `Set-Cookie` headers carry.
99pub fn response_cookies(response: &http::Response<Body>) -> Vec<(String, String)> {
100    response
101        .headers()
102        .get_all(http::header::SET_COOKIE)
103        .iter()
104        .filter_map(|value| value.to_str().ok())
105        .filter_map(|value| value.split(';').next())
106        .filter_map(|pair| pair.split_once('='))
107        .map(|(name, value)| (name.trim().to_string(), value.trim().to_string()))
108        .collect()
109}
110
111/// The full `Set-Cookie` header for `name`, so tests can assert attributes.
112pub fn set_cookie_header(response: &http::Response<Body>, name: &str) -> Option<String> {
113    response
114        .headers()
115        .get_all(http::header::SET_COOKIE)
116        .iter()
117        .filter_map(|value| value.to_str().ok())
118        .find(|value| value.starts_with(&format!("{name}=")))
119        .map(str::to_string)
120}
121
122/// The session cookie value a response set, if any.
123pub fn session_cookie_value(response: &http::Response<Body>) -> Option<String> {
124    response_cookies(response)
125        .into_iter()
126        .find(|(name, _)| name == SESSION_COOKIE)
127        .map(|(_, value)| value)
128}