aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorA Farzat <a@farzat.xyz>2026-02-11 10:09:32 +0300
committerA Farzat <a@farzat.xyz>2026-02-11 10:09:32 +0300
commit3321918c009e9d7a7a3c3c2a1f490bb91fefb2bc (patch)
tree261a7228b3199c689e6970effd9bb8d4fc609531 /src
parent001304d3f27a5fa1ca4d06d4c352d248b45640e0 (diff)
downloadsafaribooks-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.rs2
-rw-r--r--src/http_client.rs111
-rw-r--r--src/main.rs10
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);