diff options
| author | A Farzat <a@farzat.xyz> | 2026-02-11 10:09:32 +0300 |
|---|---|---|
| committer | A Farzat <a@farzat.xyz> | 2026-02-11 10:09:32 +0300 |
| commit | 3321918c009e9d7a7a3c3c2a1f490bb91fefb2bc (patch) | |
| tree | 261a7228b3199c689e6970effd9bb8d4fc609531 /src | |
| parent | 001304d3f27a5fa1ca4d06d4c352d248b45640e0 (diff) | |
| download | safaribooks-rs-3321918c009e9d7a7a3c3c2a1f490bb91fefb2bc.tar.gz safaribooks-rs-3321918c009e9d7a7a3c3c2a1f490bb91fefb2bc.zip | |
Add reqwest HttpClient skeleton (cookies-only)
- Build a reqwest::Client with Cookie and browser-like default headers
- Wire into main without performing any HTTP calls
Note: Keep cookie header internal; never log values
Diffstat (limited to 'src')
| -rw-r--r-- | src/cookies.rs | 2 | ||||
| -rw-r--r-- | src/http_client.rs | 111 | ||||
| -rw-r--r-- | src/main.rs | 10 |
3 files changed, 121 insertions, 2 deletions
diff --git a/src/cookies.rs b/src/cookies.rs index 4e33dcc..f7b0a63 100644 --- a/src/cookies.rs +++ b/src/cookies.rs @@ -26,7 +26,7 @@ pub struct CookieStore { impl CookieStore { /// Create a CookieStore from a serde_json::Value (already parsed). - fn from_value(v: Value) -> anyhow::Result<Self> { + pub fn from_value(v: Value) -> anyhow::Result<Self> { // Try to deserialize into either a map or a list. let cj: CookiesJson = serde_json::from_value(v)?; let mut map = HashMap::new(); diff --git a/src/http_client.rs b/src/http_client.rs new file mode 100644 index 0000000..8272c8c --- /dev/null +++ b/src/http_client.rs @@ -0,0 +1,111 @@ +use crate::cookies::CookieStore; +use anyhow::Result; +use reqwest::header::{ + HeaderMap, HeaderValue, ACCEPT, ACCEPT_ENCODING, COOKIE, REFERER, USER_AGENT, +}; +use reqwest::Client; + +/// Minimal HTTP client wrapper. +/// - Cookies are injected into the default `Cookie:` header. +/// - A few "browser-like" headers are pre-set (matching the spirit of the Python script). +pub struct HttpClient { + client: Client, + /// Kept for tests and internal checks; **do not log** this in production logs. + cookie_header: String, +} + +impl HttpClient { + /// Build a HeaderMap with static browser-like values and an explicit Cookie header. + fn build_default_headers(cookie_header: &str) -> Result<HeaderMap> { + let mut headers = HeaderMap::new(); + + // User-Agent: a modern desktop UA string (no device-specific flags). + headers.insert( + USER_AGENT, + HeaderValue::from_static( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \ + (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + ), + ); + + // Accept: prefer HTML, XML; also allow images and generic types. + headers.insert( + ACCEPT, + HeaderValue::from_static( + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + ), + ); + + // Accept-Encoding: Inform the server we can accept gzip/deflate. + // (reqwest handles decompression automatically.) + headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("gzip, deflate")); + + // Referer: mirrors the original script's "login entry" intent (safe placeholder for now). + headers.insert( + REFERER, + HeaderValue::from_static("https://learning.oreilly.com/login/unified/?next=/home/"), + ); + + // Cookie: **all authentication lives here** (cookies-only flow). + // IMPORTANT: HeaderValue::from_str validates and rejects invalid bytes. + headers.insert(COOKIE, HeaderValue::from_str(cookie_header)?); + + Ok(headers) + } + + /// Create an HttpClient from a CookieStore (preferred path). + pub fn from_store(store: &CookieStore) -> Result<Self> { + let cookie_header = store.to_header_value(); + Self::new(&cookie_header) + } + + /// Create an HttpClient from a pre-rendered "Cookie: ..." value. + pub fn new(cookie_header: &str) -> Result<Self> { + let headers = Self::build_default_headers(cookie_header)?; + let client = Client::builder().default_headers(headers).build()?; + Ok(Self { + client, + cookie_header: cookie_header.to_string(), + }) + } + + /// Access the underlying reqwest client (read-only). + pub fn client(&self) -> &Client { + &self.client + } + + /// Expose the cookie header for tests/diagnostics (do **not** log this in production). + pub fn cookie_header(&self) -> &str { + &self.cookie_header + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cookies::CookieStore; + use serde_json::json; + + #[test] + fn builds_client_with_cookie_header_from_map() { + let v = json!({ "sess": "abc", "OptanonConsent": "xyz" }); + let store = CookieStore::from_value(v).unwrap(); + let hc = HttpClient::from_store(&store).unwrap(); + + // Deterministic order (sorted by name) + assert_eq!(hc.cookie_header(), "OptanonConsent=xyz; sess=abc"); + // We don't assert on internal reqwest headers here; the presence of the header value suffices. + } + + #[test] + fn builds_client_with_cookie_header_from_list() { + let v = json!([ + {"name": "a", "value": "1"}, + {"name": "b", "value": "2"} + ]); + let store = CookieStore::from_value(v).unwrap(); + let hc = HttpClient::from_store(&store).unwrap(); + + assert_eq!(hc.cookie_header(), "a=1; b=2"); + } +} diff --git a/src/main.rs b/src/main.rs index e582af9..6981b8e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,15 +2,16 @@ mod cli; mod config; mod cookies; mod display; +mod http_client; use clap::Parser; use cli::Args; use cookies::CookieStore; use display::Display; +use http_client::HttpClient; fn main() { let args = Args::parse(); - let mut ui = Display::new(&args.bookid); let cookies_path = config::cookies_file(); @@ -38,6 +39,13 @@ fn main() { names.join(", ") )); + // Build the HTTP client with our cookies (no network calls yet). + let _client = match HttpClient::from_store(&store) { + Ok(c) => c, + Err(e) => ui.error_and_exit(&format!("Failed to build HTTP client: {e}")), + }; + ui.info("HTTP client initialized with cookies (no requests performed)."); + let output_dir = config::books_root().join(format!("(pending) ({})", args.bookid)); ui.set_output_dir(output_dir); |
