1pub mod fake;
18pub mod stream;
19pub mod url;
20
21use rustlavel_core::events::Event;
22use rustlavel_core::{Error, Json, Result};
23use rustlavel_http::{Headers, Method, Status};
24use std::sync::Arc;
25use std::time::{Duration, Instant};
26use tokio::io::{AsyncReadExt, AsyncWriteExt};
27use tokio::net::TcpStream;
28use url::Url;
29
30pub use fake::{Fake, FakeResponse};
31pub use stream::{Body, ServerSentEvent, SseReader};
32
33#[derive(Debug, Clone)]
35pub struct ClientResponse {
36 pub status: Status,
37 pub headers: Headers,
38 pub body: Vec<u8>,
39}
40
41impl ClientResponse {
42 pub fn text(&self) -> String {
43 String::from_utf8_lossy(&self.body).into_owned()
44 }
45
46 pub fn json(&self) -> Result<Json> {
47 Json::parse(&self.text())
48 }
49
50 pub fn is_success(&self) -> bool {
51 self.status.is_success()
52 }
53
54 pub fn error_for_status(self) -> Result<ClientResponse> {
57 if self.is_success() {
58 return Ok(self);
59 }
60 let body = self.text();
61 let excerpt = if body.len() > 500 { format!("{}…", &body[..500]) } else { body };
62 Err(Error::msg(format!("HTTP {}: {excerpt}", self.status)))
63 }
64}
65
66#[derive(Clone)]
68pub struct Client {
69 timeout: Duration,
70 retries: u32,
72 default_headers: Headers,
73 max_body_bytes: usize,
74 fake: Option<Arc<Fake>>,
75}
76
77impl Default for Client {
78 fn default() -> Self {
79 let mut default_headers = Headers::new();
80 default_headers.set("user-agent", concat!("rustlavel/", env!("CARGO_PKG_VERSION")));
81 default_headers.set("accept", "*/*");
82
83 Client {
84 timeout: Duration::from_secs(30),
85 retries: 0,
86 default_headers,
87 max_body_bytes: 32 * 1024 * 1024,
88 fake: None,
89 }
90 }
91}
92
93impl Client {
94 pub fn new() -> Self {
95 Client::default()
96 }
97
98 pub fn timeout(mut self, timeout: Duration) -> Self {
99 self.timeout = timeout;
100 self
101 }
102
103 pub fn retries(mut self, retries: u32) -> Self {
108 self.retries = retries;
109 self
110 }
111
112 pub fn default_header(mut self, name: &str, value: impl Into<String>) -> Self {
113 self.default_headers.set(name, value);
114 self
115 }
116
117 pub fn faking(mut self, fake: Fake) -> Self {
122 self.fake = Some(Arc::new(fake));
123 self
124 }
125
126 pub fn fake(&self) -> Option<&Arc<Fake>> {
127 self.fake.as_ref()
128 }
129
130 pub fn request(&self, method: Method, url: impl Into<String>) -> RequestBuilder {
131 RequestBuilder {
132 client: self.clone(),
133 method,
134 url: url.into(),
135 headers: self.default_headers.clone(),
136 body: Vec::new(),
137 }
138 }
139
140 pub fn get(&self, url: impl Into<String>) -> RequestBuilder {
141 self.request(Method::Get, url)
142 }
143
144 pub fn post(&self, url: impl Into<String>) -> RequestBuilder {
145 self.request(Method::Post, url)
146 }
147
148 pub fn put(&self, url: impl Into<String>) -> RequestBuilder {
149 self.request(Method::Put, url)
150 }
151
152 pub fn patch(&self, url: impl Into<String>) -> RequestBuilder {
153 self.request(Method::Patch, url)
154 }
155
156 pub fn delete(&self, url: impl Into<String>) -> RequestBuilder {
157 self.request(Method::Delete, url)
158 }
159}
160
161pub struct RequestBuilder {
163 client: Client,
164 method: Method,
165 url: String,
166 headers: Headers,
167 body: Vec<u8>,
168}
169
170impl RequestBuilder {
171 pub fn header(mut self, name: &str, value: impl Into<String>) -> Self {
172 self.headers.set(name, value);
173 self
174 }
175
176 pub fn bearer(self, token: &str) -> Self {
177 self.header("authorization", format!("Bearer {token}"))
178 }
179
180 pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
181 self.body = body.into();
182 self
183 }
184
185 pub fn json(self, value: Json) -> Self {
186 self.header("content-type", "application/json").body(value.to_string())
187 }
188
189 pub fn accept_events(self) -> Self {
191 self.header("accept", "text/event-stream")
192 }
193
194 pub fn method(&self) -> Method {
195 self.method
196 }
197
198 pub fn url(&self) -> &str {
199 &self.url
200 }
201
202 pub fn headers(&self) -> &Headers {
203 &self.headers
204 }
205
206 pub fn body_bytes(&self) -> &[u8] {
207 &self.body
208 }
209
210 pub async fn send(self) -> Result<ClientResponse> {
212 let started = Instant::now();
213 let method = self.method;
214 let url = self.url.clone();
215
216 if let Some(fake) = self.client.fake.clone() {
219 let response = fake.respond(&self)?;
220 record(method, &url, Some(response.status), started);
221 return Ok(response);
222 }
223
224 let mut attempt = 0;
225 loop {
226 match self.send_once().await {
227 Ok(response) => {
228 record(method, &url, Some(response.status), started);
229 return Ok(response);
230 }
231 Err(error) if attempt < self.client.retries && is_retryable(&error) => {
232 let backoff = Duration::from_millis(100 * 2u64.pow(attempt));
233 rustlavel_core::debug!("retrying {method} {url} after {error}");
234 tokio::time::sleep(backoff).await;
235 attempt += 1;
236 }
237 Err(error) => {
238 record(method, &url, None, started);
239 return Err(error);
240 }
241 }
242 }
243 }
244
245 pub async fn stream(self) -> Result<Body> {
247 if let Some(fake) = self.client.fake.clone() {
248 let response = fake.respond(&self)?;
249 return Ok(Body::from_bytes(response.status, response.headers, response.body));
250 }
251
252 let url = Url::parse(&self.url)?;
253 let stream = connect(&url, self.client.timeout).await?;
254 let request = self.wire(&url);
255
256 stream::open(stream, request, self.client.timeout).await
257 }
258
259 async fn send_once(&self) -> Result<ClientResponse> {
260 let url = Url::parse(&self.url)?;
261 let mut stream = connect(&url, self.client.timeout).await?;
262 let request = self.wire(&url);
263
264 let exchange = async {
265 stream.write_all(&request).await.map_err(Error::Io)?;
266 stream.flush().await.map_err(Error::Io)?;
267 read_response(&mut stream, self.client.max_body_bytes).await
268 };
269
270 tokio::time::timeout(self.client.timeout, exchange)
271 .await
272 .map_err(|_| Error::msg(format!("{} {} timed out", self.method, self.url)))?
273 }
274
275 fn wire(&self, url: &Url) -> Vec<u8> {
277 let mut head = format!("{} {} HTTP/1.1\r\n", self.method, url.target);
278 head.push_str(&format!("host: {}\r\n", url.authority()));
279
280 for (name, value) in self.headers.iter() {
281 if name == "host" || name == "content-length" || name == "connection" {
282 continue;
283 }
284 head.push_str(&format!("{name}: {value}\r\n"));
285 }
286
287 head.push_str("connection: close\r\n");
290 if !self.body.is_empty() || self.method.takes_body() {
291 head.push_str(&format!("content-length: {}\r\n", self.body.len()));
292 }
293 head.push_str("\r\n");
294
295 let mut out = head.into_bytes();
296 out.extend_from_slice(&self.body);
297 out
298 }
299}
300
301fn record(method: Method, url: &str, status: Option<Status>, started: Instant) {
302 if !rustlavel_core::events::has_subscribers() {
303 return;
304 }
305 let mut event = Event::new("http.client")
306 .with("method", method.as_str())
307 .with("url", url)
308 .took(started.elapsed());
309 if let Some(status) = status {
310 event = event.with("status", status.code());
311 }
312 event.dispatch();
313}
314
315fn is_retryable(error: &Error) -> bool {
317 let text = error.to_string();
318 text.contains("timed out")
319 || text.contains("Connection refused")
320 || text.contains("connection reset")
321 || text.contains("Temporary failure")
322}
323
324pub enum Connection {
329 Plain(TcpStream),
330 Tls(Box<tokio_rustls::client::TlsStream<TcpStream>>),
331}
332
333impl Connection {
334 pub async fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
335 match self {
336 Connection::Plain(stream) => stream.write_all(bytes).await,
337 Connection::Tls(stream) => stream.write_all(bytes).await,
338 }
339 }
340
341 pub async fn flush(&mut self) -> std::io::Result<()> {
342 match self {
343 Connection::Plain(stream) => stream.flush().await,
344 Connection::Tls(stream) => stream.flush().await,
345 }
346 }
347
348 pub async fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
349 match self {
350 Connection::Plain(stream) => stream.read(buffer).await,
351 Connection::Tls(stream) => stream.read(buffer).await,
352 }
353 }
354}
355
356pub async fn connect(url: &Url, timeout: Duration) -> Result<Connection> {
358 let address = url.socket_address();
359
360 let tcp = tokio::time::timeout(timeout, TcpStream::connect(&address))
361 .await
362 .map_err(|_| Error::msg(format!("connecting to {address} timed out")))?
363 .map_err(|e| Error::msg(format!("cannot connect to {address}: {e}")))?;
364
365 let _ = tcp.set_nodelay(true);
366
367 if !url.secure {
368 return Ok(Connection::Plain(tcp));
369 }
370
371 let connector = tls_connector();
372 let server_name = rustls::pki_types::ServerName::try_from(url.host.clone())
373 .map_err(|_| Error::msg(format!("`{}` is not a valid TLS server name", url.host)))?;
374
375 let tls = connector
376 .connect(server_name, tcp)
377 .await
378 .map_err(|e| Error::msg(format!("TLS handshake with {} failed: {e}", url.host)))?;
379
380 Ok(Connection::Tls(Box::new(tls)))
381}
382
383fn tls_connector() -> tokio_rustls::TlsConnector {
395 use std::sync::OnceLock;
396 static CONNECTOR: OnceLock<tokio_rustls::TlsConnector> = OnceLock::new();
397
398 CONNECTOR
399 .get_or_init(|| {
400 let roots = rustls::RootCertStore {
401 roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
402 };
403 let config = rustls::ClientConfig::builder()
404 .with_root_certificates(roots)
405 .with_no_client_auth();
406 tokio_rustls::TlsConnector::from(Arc::new(config))
407 })
408 .clone()
409}
410
411async fn read_response(connection: &mut Connection, max_body: usize) -> Result<ClientResponse> {
413 let mut buffer = Vec::with_capacity(8 * 1024);
414
415 let head_end = loop {
416 if let Some(at) = find_head_end(&buffer) {
417 break at;
418 }
419 if !fill(connection, &mut buffer).await? {
420 return Err(Error::Protocol("the server closed before sending headers".into()));
421 }
422 if buffer.len() > 256 * 1024 {
423 return Err(Error::Protocol("response headers are too large".into()));
424 }
425 };
426
427 let (status, headers) = parse_head(&buffer[..head_end])?;
428 let mut body = buffer.split_off(head_end);
429
430 if headers.get("transfer-encoding").is_some_and(|te| te.contains("chunked")) {
431 body = read_chunked(connection, body, max_body).await?;
432 } else if let Some(length) = headers.content_length() {
433 if length > max_body {
434 return Err(Error::Protocol("response body is too large".into()));
435 }
436 while body.len() < length {
437 if !fill_into(connection, &mut body).await? {
438 return Err(Error::Protocol("response body ended early".into()));
439 }
440 }
441 body.truncate(length);
442 } else {
443 while fill_into(connection, &mut body).await? {
446 if body.len() > max_body {
447 return Err(Error::Protocol("response body is too large".into()));
448 }
449 }
450 }
451
452 Ok(ClientResponse { status, headers, body })
453}
454
455pub(crate) fn parse_head(head: &[u8]) -> Result<(Status, Headers)> {
456 let text = std::str::from_utf8(head)
457 .map_err(|_| Error::Protocol("response headers are not UTF-8".into()))?;
458 let mut lines = text.split("\r\n");
459
460 let status_line = lines.next().ok_or_else(|| Error::Protocol("empty response".into()))?;
461 let code = status_line
462 .split(' ')
463 .nth(1)
464 .and_then(|code| code.parse::<u16>().ok())
465 .ok_or_else(|| Error::Protocol(format!("malformed status line: {status_line}")))?;
466
467 let mut headers = Headers::new();
468 for line in lines {
469 if line.is_empty() {
470 continue;
471 }
472 if let Some((name, value)) = line.split_once(':') {
473 headers.append(name.trim(), value.trim());
474 }
475 }
476
477 Ok((Status(code), headers))
478}
479
480async fn read_chunked(
481 connection: &mut Connection,
482 mut buffer: Vec<u8>,
483 max_body: usize,
484) -> Result<Vec<u8>> {
485 let mut body = Vec::new();
486
487 loop {
488 let line_end = loop {
489 if let Some(at) = find_crlf(&buffer) {
490 break at;
491 }
492 if !fill_into(connection, &mut buffer).await? {
493 return Err(Error::Protocol("chunked body ended early".into()));
494 }
495 };
496
497 let header: Vec<u8> = buffer.drain(..line_end + 2).collect();
498 let size_text = String::from_utf8_lossy(&header[..line_end]);
499 let size = usize::from_str_radix(size_text.split(';').next().unwrap_or("").trim(), 16)
500 .map_err(|_| Error::Protocol("invalid chunk size".into()))?;
501
502 if size == 0 {
503 return Ok(body);
504 }
505 if body.len() + size > max_body {
506 return Err(Error::Protocol("response body is too large".into()));
507 }
508
509 while buffer.len() < size + 2 {
510 if !fill_into(connection, &mut buffer).await? {
511 return Err(Error::Protocol("chunked body ended early".into()));
512 }
513 }
514 body.extend(buffer.drain(..size));
515 buffer.drain(..2);
516 }
517}
518
519async fn fill(connection: &mut Connection, buffer: &mut Vec<u8>) -> Result<bool> {
520 fill_into(connection, buffer).await
521}
522
523async fn fill_into(connection: &mut Connection, buffer: &mut Vec<u8>) -> Result<bool> {
524 let mut chunk = [0u8; 8192];
525 let read = connection.read(&mut chunk).await.map_err(Error::Io)?;
526 buffer.extend_from_slice(&chunk[..read]);
527 Ok(read > 0)
528}
529
530pub(crate) fn find_head_end(buffer: &[u8]) -> Option<usize> {
531 buffer.windows(4).position(|w| w == b"\r\n\r\n").map(|at| at + 4)
532}
533
534fn find_crlf(buffer: &[u8]) -> Option<usize> {
535 buffer.windows(2).position(|w| w == b"\r\n")
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 #[test]
543 fn builds_a_request_line_and_headers() {
544 let client = Client::new();
545 let builder = client
546 .post("https://example.com/v1/things?x=1")
547 .bearer("secret")
548 .json(Json::object([("name", "widget".into())]));
549
550 let wire = String::from_utf8(builder.wire(&Url::parse(builder.url()).unwrap())).unwrap();
551
552 assert!(wire.starts_with("POST /v1/things?x=1 HTTP/1.1\r\n"));
553 assert!(wire.contains("host: example.com\r\n"));
554 assert!(wire.contains("authorization: Bearer secret\r\n"));
555 assert!(wire.contains("content-type: application/json\r\n"));
556 assert!(wire.contains("content-length: 17\r\n"));
557 assert!(wire.ends_with("\r\n\r\n{\"name\":\"widget\"}"));
558 }
559
560 #[test]
561 fn parses_a_response_head() {
562 let head = b"HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n";
563 let (status, headers) = parse_head(head).unwrap();
564
565 assert_eq!(status, Status::CREATED);
566 assert_eq!(headers.content_type(), Some("application/json"));
567 assert_eq!(headers.content_length(), Some(2));
568 }
569
570 #[test]
571 fn a_failed_status_becomes_an_error_carrying_the_body() {
572 let response = ClientResponse {
573 status: Status(429),
574 headers: Headers::new(),
575 body: b"{\"error\":\"rate limited\"}".to_vec(),
576 };
577
578 let error = response.error_for_status().unwrap_err().to_string();
579 assert!(error.contains("429"));
580 assert!(error.contains("rate limited"));
581 }
582
583 #[test]
584 fn only_transport_failures_are_retried() {
585 assert!(is_retryable(&Error::msg("connecting to x timed out")));
586 assert!(is_retryable(&Error::msg("cannot connect to x: Connection refused (os error 61)")));
587 assert!(!is_retryable(&Error::msg("HTTP 500 Internal Server Error: boom")));
588 }
589
590 #[tokio::test]
591 async fn talks_to_a_real_server_over_plain_http() {
592 use rustlavel_http::{Request, Response, Router, Server};
595 use rustlavel_core::Context;
596
597 let mut router = Router::new();
598 router.post("/echo", |mut req: Request| async move {
599 Response::json(Json::object([
600 ("saw", Json::from(req.input("name").unwrap_or_default())),
601 ("agent", Json::from(req.header("user-agent").unwrap_or("").to_string())),
602 ]))
603 });
604
605 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
606 let address = listener.local_addr().unwrap();
607 drop(listener);
608
609 let server = Server::new(router, Context::default());
610 tokio::spawn(async move {
611 let _ = server.listen(address.to_string()).await;
612 });
613 tokio::time::sleep(Duration::from_millis(150)).await;
615
616 let response = Client::new()
617 .post(format!("http://{address}/echo"))
618 .json(Json::object([("name", "ada".into())]))
619 .send()
620 .await
621 .unwrap()
622 .error_for_status()
623 .unwrap();
624
625 let body = response.json().unwrap();
626 assert_eq!(body.get("saw").unwrap().as_str(), Some("ada"));
627 assert!(body.get("agent").unwrap().as_str().unwrap().starts_with("rustlavel/"));
628 }
629
630 #[tokio::test]
631 async fn a_connection_failure_is_reported_clearly() {
632 let error = Client::new()
633 .timeout(Duration::from_millis(500))
634 .get("http://127.0.0.1:1/nope")
635 .send()
636 .await
637 .unwrap_err()
638 .to_string();
639
640 assert!(error.contains("127.0.0.1:1"), "{error}");
641 }
642
643 #[test]
658 fn the_key_exchange_leads_with_a_post_quantum_hybrid() {
659 let config = rustls::ClientConfig::builder()
663 .with_root_certificates(rustls::RootCertStore::empty())
664 .with_no_client_auth();
665
666 let offered: Vec<String> = config
667 .crypto_provider()
668 .kx_groups
669 .iter()
670 .map(|group| format!("{:?}", group.name()))
671 .collect();
672
673 assert_eq!(
674 offered.first().map(String::as_str),
675 Some("X25519MLKEM768"),
676 "the post-quantum hybrid must lead the ClientHello; offered: {offered:?}"
677 );
678 assert!(
679 offered.iter().any(|name| name == "X25519"),
680 "a classical group must remain, for servers that do not know the hybrid: {offered:?}"
681 );
682 }
683}