relay_knowledge/net/http/
outbound.rs1use std::{error::Error, fmt, io, time::Duration};
2
3use serde_json::Value;
4use tokio::io::{AsyncReadExt, AsyncWriteExt};
5
6use crate::net::qos::{QosPolicy, QosRuntime, RejectReason};
7
8use super::{HttpConfig, qos_request_context_active};
9
10pub fn outbound_json_client(config: &HttpConfig) -> Result<reqwest::Client, OutboundClientError> {
12 outbound_json_client_with_policy(config, None, None)
13}
14
15pub fn outbound_json_client_with_policy(
17 config: &HttpConfig,
18 ssl_verify: Option<bool>,
19 connect_timeout: Option<Duration>,
20) -> Result<reqwest::Client, OutboundClientError> {
21 let mut builder = reqwest::Client::builder()
22 .timeout(config.request_timeout)
23 .danger_accept_invalid_certs(!ssl_verify.unwrap_or(config.proxy.ssl_verify));
24 if let Some(timeout) = connect_timeout {
25 builder = builder.connect_timeout(timeout);
26 }
27 if let Some(proxy_url) = &config.proxy.proxy {
28 let no_proxy = reqwest::NoProxy::from_string(&config.proxy.no_proxy_rules.join(","));
29 let proxy = reqwest::Proxy::all(proxy_url)
30 .map_err(|error| OutboundClientError {
31 message: error.to_string(),
32 })?
33 .no_proxy(no_proxy);
34 builder = builder.proxy(proxy);
35 }
36
37 builder.build().map_err(|error| OutboundClientError {
38 message: error.to_string(),
39 })
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct OutboundClientError {
44 pub message: String,
45}
46impl fmt::Display for OutboundClientError {
47 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48 self.message.fmt(formatter)
49 }
50}
51
52impl Error for OutboundClientError {}
53
54#[derive(Debug)]
56pub enum HttpClientError {
57 InvalidUrl(String),
58 QosRejected(RejectReason),
59 Io(io::Error),
60 Timeout,
61 InvalidResponse,
62 ResponseStatus(u16),
63 ResponseJson(serde_json::Error),
64}
65
66impl fmt::Display for HttpClientError {
67 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68 match self {
69 Self::InvalidUrl(value) => write!(formatter, "invalid HTTP worker URL: {value}"),
70 Self::QosRejected(reason) => write!(
71 formatter,
72 "HTTP worker request rejected by QoS: {}",
73 reason.as_str()
74 ),
75 Self::Io(error) => write!(formatter, "HTTP worker request failed: {error}"),
76 Self::Timeout => write!(formatter, "HTTP worker request timed out"),
77 Self::InvalidResponse => write!(formatter, "HTTP worker returned invalid response"),
78 Self::ResponseStatus(status) => {
79 write!(formatter, "HTTP worker returned status {status}")
80 }
81 Self::ResponseJson(error) => {
82 write!(formatter, "HTTP worker returned invalid JSON: {error}")
83 }
84 }
85 }
86}
87
88impl Error for HttpClientError {}
89
90pub async fn post_json(
92 config: &HttpConfig,
93 url: &str,
94 payload: &Value,
95) -> Result<Value, HttpClientError> {
96 let request = JsonHttpRequest::parse(url)?;
97 let body = serde_json::to_vec(payload).map_err(HttpClientError::ResponseJson)?;
98 let response = tokio::time::timeout(config.request_timeout, send_json_request(request, body))
99 .await
100 .map_err(|_| HttpClientError::Timeout)??;
101
102 serde_json::from_slice(&response).map_err(HttpClientError::ResponseJson)
103}
104
105pub async fn post_json_with_qos(
107 config: &HttpConfig,
108 qos: &QosRuntime,
109 policy: &QosPolicy,
110 url: &str,
111 payload: &Value,
112) -> Result<Value, HttpClientError> {
113 let permit = if qos_request_context_active() {
114 None
115 } else {
116 Some(
117 qos.admit_request(policy)
118 .map_err(HttpClientError::QosRejected)?,
119 )
120 };
121 let result = post_json(config, url, payload).await;
122 drop(permit);
123 if matches!(result, Err(HttpClientError::Timeout)) {
124 qos.record_timed_out();
125 }
126
127 result
128}
129
130struct JsonHttpRequest {
131 host: String,
132 port: u16,
133 path: String,
134}
135
136impl JsonHttpRequest {
137 fn parse(value: &str) -> Result<Self, HttpClientError> {
138 let remainder = value
139 .strip_prefix("http://")
140 .ok_or_else(|| HttpClientError::InvalidUrl(value.to_owned()))?;
141 let (authority, path) = remainder
142 .split_once('/')
143 .map_or((remainder, "/"), |(authority, path)| {
144 (authority, path.trim_start_matches('/'))
145 });
146 if authority.is_empty() {
147 return Err(HttpClientError::InvalidUrl(value.to_owned()));
148 }
149 let (host, port) = authority
150 .rsplit_once(':')
151 .map(|(host, port)| {
152 let parsed_port = port
153 .parse::<u16>()
154 .map_err(|_| HttpClientError::InvalidUrl(value.to_owned()))?;
155 Ok((host.to_owned(), parsed_port))
156 })
157 .unwrap_or_else(|| Ok((authority.to_owned(), 80)))?;
158 if host.is_empty() || port == 0 {
159 return Err(HttpClientError::InvalidUrl(value.to_owned()));
160 }
161 let path = if path.is_empty() {
162 "/".to_owned()
163 } else {
164 format!("/{path}")
165 };
166
167 Ok(Self { host, port, path })
168 }
169}
170
171async fn send_json_request(
172 request: JsonHttpRequest,
173 body: Vec<u8>,
174) -> Result<Vec<u8>, HttpClientError> {
175 let mut stream = tokio::net::TcpStream::connect((request.host.as_str(), request.port))
176 .await
177 .map_err(HttpClientError::Io)?;
178 let head = format!(
179 "POST {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nAccept: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n",
180 request.path,
181 request.host,
182 body.len()
183 );
184 stream
185 .write_all(head.as_bytes())
186 .await
187 .map_err(HttpClientError::Io)?;
188 stream.write_all(&body).await.map_err(HttpClientError::Io)?;
189 stream.shutdown().await.map_err(HttpClientError::Io)?;
190 let mut response = Vec::new();
191 stream
192 .read_to_end(&mut response)
193 .await
194 .map_err(HttpClientError::Io)?;
195 parse_http_response(response)
196}
197
198fn parse_http_response(response: Vec<u8>) -> Result<Vec<u8>, HttpClientError> {
199 let Some(header_end) = response.windows(4).position(|window| window == b"\r\n\r\n") else {
200 return Err(HttpClientError::InvalidResponse);
201 };
202 let headers = std::str::from_utf8(&response[..header_end])
203 .map_err(|_| HttpClientError::InvalidResponse)?;
204 let status = headers
205 .lines()
206 .next()
207 .and_then(|line| line.split_whitespace().nth(1))
208 .and_then(|value| value.parse::<u16>().ok())
209 .ok_or(HttpClientError::InvalidResponse)?;
210 if !(200..300).contains(&status) {
211 return Err(HttpClientError::ResponseStatus(status));
212 }
213
214 Ok(response[header_end + 4..].to_vec())
215}
216
217#[cfg(test)]
218#[path = "outbound_tests.rs"]
219mod outbound_tests;