Skip to main content

xyo_http/
lib.rs

1mod err;
2
3use native_tls::TlsConnector;
4use std::io::{Read, Write};
5use std::net::TcpStream;
6use std::time::Duration;
7pub use crate::err::HttpClientError;
8
9#[derive(Debug)]
10pub enum HttpMethod {
11    GET,
12    POST,
13}
14
15const HOST: &str = "api.xyo.financial";
16const PORT: i32 = 443;
17const DEFAULT_TIMEOUT_DURATION: Duration = Duration::from_secs(10);
18
19#[derive(Debug)]
20pub struct HttpResponse {
21    pub status_code: i16,
22    pub body: String,
23}
24
25mod http_message {
26    use crate::{HttpMethod, HOST};
27
28    /// Constructs the RFC Standard Header for HTTP 1.1 Specs, safely parsing headers and body length.
29    pub fn new(method: HttpMethod, path: &str, headers: &[(&str, &str)], data: &str) -> String {
30        let method_str = match method {
31            HttpMethod::GET => "GET",
32            HttpMethod::POST => "POST",
33        };
34
35        // HTTP/1.1 requires Host header and we enforce Connection: close for synchronous non-keep-alive sockets
36        let mut req = format!(
37            "{} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n",
38            method_str, path, HOST
39        );
40
41        // Inject dynamic headers (like Authorization)
42        for (k, v) in headers {
43            req.push_str(&format!("{}: {}\r\n", k, v));
44        }
45
46        // Apply defaults if not provided in headers
47        if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Content-Type")) && !data.is_empty() {
48            req.push_str("Content-Type: application/json\r\n");
49        }
50        if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Accept")) {
51            req.push_str("Accept: application/json\r\n");
52        }
53
54        if !data.is_empty() {
55            req.push_str(&format!("Content-Length: {}\r\n", data.as_bytes().len()));
56        }
57
58        // End of headers
59        req.push_str("\r\n");
60        
61        // Append body
62        if !data.is_empty() {
63            req.push_str(data);
64        }
65
66        req
67    }
68}
69
70/// Send an HTTP request to the API, now securely via TLS and supporting header injection.
71pub fn request(
72    method: HttpMethod,
73    path: &str,
74    headers: &[(&str, &str)],
75    data: &str,
76) -> Result<HttpResponse, HttpClientError> {
77    let tcp_stream_socket = TcpStream::connect(format!("{}:{}", HOST, PORT)).map_err(|e| {
78        HttpClientError {
79            code: 503,
80            message: format!("could not connect to host: {} and port number {}: {}", HOST, PORT, e),
81        }
82    })?;
83
84    tcp_stream_socket
85        .set_read_timeout(Some(DEFAULT_TIMEOUT_DURATION))
86        .map_err(|e| HttpClientError {
87            code: 500,
88            message: format!("failed to set read timeout: {}", e),
89        })?;
90
91    let connector = TlsConnector::new().map_err(|e| HttpClientError {
92        code: 500,
93        message: format!("failed to create TLS connector: {}", e),
94    })?;
95
96    let mut secure_stream = connector
97        .connect(HOST, tcp_stream_socket)
98        .map_err(|e| HttpClientError {
99            code: 500,
100            message: format!("failed TLS handshake with {}: {}", HOST, e),
101        })?;
102
103    let req_payload = http_message::new(method, path, headers, data);
104    
105    secure_stream
106        .write_all(req_payload.as_bytes())
107        .map_err(|e| HttpClientError {
108            code: 500,
109            message: format!("failed to write to socket: {}", e),
110        })?;
111
112    // Read the entire response (headers + body) until EOF (which is safe because Connection: close)
113    let mut resp_bytes = Vec::new();
114    secure_stream
115        .read_to_end(&mut resp_bytes)
116        .map_err(|e| HttpClientError {
117            code: 500,
118            message: format!("failed to read from socket: {}", e),
119        })?;
120
121    let _ = secure_stream.flush();
122
123    // Parse HTTP response safely without assuming \r\n separated JSON body
124    let response_str = String::from_utf8_lossy(&resp_bytes);
125    let mut parts = response_str.splitn(2, "\r\n\r\n");
126    let header_part = parts.next().unwrap_or("");
127    let body_part = parts.next().unwrap_or("").to_string();
128
129    let status_line = header_part.lines().next().unwrap_or("");
130    let status_parts: Vec<&str> = status_line.splitn(3, ' ').collect();
131    let status_code: i16 = if status_parts.len() >= 2 {
132        status_parts[1].parse().unwrap_or(500)
133    } else {
134        500
135    };
136
137    Ok(HttpResponse {
138        status_code,
139        body: body_part,
140    })
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn it_works_without_body_content() {
149        // Just a simple ping to see if TLS + Parsing works. Healthz might not be an endpoint, so it might 404, but it shouldn't error locally
150        let result = request(HttpMethod::GET, "/healthz", &[], "");
151        assert!(result.is_ok());
152    }
153}