1use std::{
10 convert::Infallible,
11 error::Error,
12 fmt,
13 future::{Future, IntoFuture, Ready, ready},
14 io,
15 net::IpAddr,
16 pin::Pin,
17 sync::{
18 Arc,
19 atomic::{AtomicU64, Ordering},
20 },
21 task::{Context, Poll},
22 time::Duration,
23};
24
25use axum::{
26 Router,
27 extract::Request,
28 http::StatusCode,
29 response::{IntoResponse, Response},
30 serve::{IncomingStream, Listener},
31};
32use serde_json::Value;
33use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
34use tower::Service;
35
36use crate::{
37 env::NetworkEnvOverrides,
38 net::qos::{QosPermit, QosPolicy, QosRuntime, RejectReason},
39};
40
41pub const DEFAULT_HTTP_BIND: &str = "127.0.0.1:8791";
42pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
43pub const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
44pub const DEFAULT_MAX_BODY_BYTES: u64 = 1_048_576;
45pub const DEFAULT_SSL_VERIFY: bool = true;
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct HttpConfig {
50 pub bind_address: HttpBindAddress,
51 pub request_timeout: Duration,
52 pub graceful_shutdown_timeout: Duration,
53 pub max_request_body_bytes: u64,
54 pub proxy: HttpProxyConfig,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct HttpBindAddress {
60 value: String,
61 port: u16,
62}
63
64impl HttpBindAddress {
65 pub fn parse(value: &str) -> Result<Self, HttpConfigError> {
67 let trimmed = value.trim();
68 if trimmed.is_empty() {
69 return Err(HttpConfigError::InvalidBindAddress {
70 value: value.to_owned(),
71 });
72 }
73
74 if let Ok(socket_addr) = trimmed.parse::<std::net::SocketAddr>() {
75 return Self::from_parts(trimmed.to_owned(), socket_addr.port());
76 }
77
78 let Some((host, port)) = trimmed.rsplit_once(':') else {
79 return Err(HttpConfigError::InvalidBindAddress {
80 value: value.to_owned(),
81 });
82 };
83
84 if host.is_empty() || host.contains('/') || host.contains(char::is_whitespace) {
85 return Err(HttpConfigError::InvalidBindAddress {
86 value: value.to_owned(),
87 });
88 }
89
90 let port = port
91 .parse::<u16>()
92 .map_err(|_| HttpConfigError::InvalidBindAddress {
93 value: value.to_owned(),
94 })?;
95
96 Self::from_parts(trimmed.to_owned(), port)
97 }
98
99 pub const fn port(&self) -> u16 {
101 self.port
102 }
103
104 fn from_parts(value: String, port: u16) -> Result<Self, HttpConfigError> {
105 if port == 0 {
106 return Err(HttpConfigError::EphemeralPort);
107 }
108
109 Ok(Self { value, port })
110 }
111}
112
113impl fmt::Display for HttpBindAddress {
114 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
115 formatter.write_str(&self.value)
116 }
117}
118
119pub fn remote_clients_allowed(config: &HttpConfig, allow_remote_clients: bool) -> bool {
121 allow_remote_clients || is_local_bind(&config.bind_address.to_string())
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct HttpProxyConfig {
127 pub proxy: Option<String>,
128 pub no_proxy_rules: Vec<String>,
129 pub ssl_verify: bool,
130}
131
132pub fn outbound_json_client(config: &HttpConfig) -> Result<reqwest::Client, OutboundClientError> {
134 outbound_json_client_with_policy(config, None, None)
135}
136
137pub fn outbound_json_client_with_policy(
139 config: &HttpConfig,
140 ssl_verify: Option<bool>,
141 connect_timeout: Option<Duration>,
142) -> Result<reqwest::Client, OutboundClientError> {
143 let mut builder = reqwest::Client::builder()
144 .timeout(config.request_timeout)
145 .danger_accept_invalid_certs(!ssl_verify.unwrap_or(config.proxy.ssl_verify));
146 if let Some(timeout) = connect_timeout {
147 builder = builder.connect_timeout(timeout);
148 }
149 if let Some(proxy_url) = &config.proxy.proxy {
150 let no_proxy = reqwest::NoProxy::from_string(&config.proxy.no_proxy_rules.join(","));
151 let proxy = reqwest::Proxy::all(proxy_url)
152 .map_err(|error| OutboundClientError {
153 message: error.to_string(),
154 })?
155 .no_proxy(no_proxy);
156 builder = builder.proxy(proxy);
157 }
158
159 builder.build().map_err(|error| OutboundClientError {
160 message: error.to_string(),
161 })
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct OutboundClientError {
166 pub message: String,
167}
168impl fmt::Display for OutboundClientError {
169 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
170 self.message.fmt(formatter)
171 }
172}
173
174impl Error for OutboundClientError {}
175
176impl HttpProxyConfig {
177 pub fn new(
179 proxy: Option<String>,
180 no_proxy_rules: Vec<String>,
181 ssl_verify: bool,
182 ) -> Result<Self, HttpConfigError> {
183 if let Some(proxy_url) = proxy.as_deref() {
184 validate_proxy_url(proxy_url)?;
185 }
186
187 for rule in &no_proxy_rules {
188 if rule.trim().is_empty() {
189 return Err(HttpConfigError::EmptyNoProxyRule);
190 }
191 }
192
193 Ok(Self {
194 proxy,
195 no_proxy_rules,
196 ssl_verify,
197 })
198 }
199
200 pub fn from_overrides(overrides: &NetworkEnvOverrides) -> Result<Self, HttpConfigError> {
202 Self::new(
203 overrides.proxy.clone(),
204 parse_no_proxy_rules(overrides.no_proxy.as_deref())?,
205 overrides.ssl_verify.unwrap_or(DEFAULT_SSL_VERIFY),
206 )
207 }
208
209 pub fn is_proxy_configured(&self) -> bool {
211 self.proxy.is_some()
212 }
213}
214
215impl HttpConfig {
216 pub fn new(
218 bind_address: HttpBindAddress,
219 request_timeout: Duration,
220 graceful_shutdown_timeout: Duration,
221 max_request_body_bytes: u64,
222 proxy: HttpProxyConfig,
223 ) -> Result<Self, HttpConfigError> {
224 if request_timeout.is_zero() {
225 return Err(HttpConfigError::ZeroDuration {
226 field: "request_timeout",
227 });
228 }
229
230 if graceful_shutdown_timeout.is_zero() {
231 return Err(HttpConfigError::ZeroDuration {
232 field: "graceful_shutdown_timeout",
233 });
234 }
235
236 if max_request_body_bytes == 0 {
237 return Err(HttpConfigError::ZeroMaxBodyBytes);
238 }
239
240 Ok(Self {
241 bind_address,
242 request_timeout,
243 graceful_shutdown_timeout,
244 max_request_body_bytes,
245 proxy,
246 })
247 }
248
249 pub fn from_overrides(overrides: &NetworkEnvOverrides) -> Result<Self, HttpConfigError> {
251 let bind_value = overrides.http_bind.as_deref().unwrap_or(DEFAULT_HTTP_BIND);
252 let bind_address = HttpBindAddress::parse(bind_value)?;
253 let request_timeout = overrides
254 .http_request_timeout_ms
255 .map(Duration::from_millis)
256 .unwrap_or(DEFAULT_REQUEST_TIMEOUT);
257 let shutdown_timeout = overrides
258 .http_shutdown_timeout_ms
259 .map(Duration::from_millis)
260 .unwrap_or(DEFAULT_SHUTDOWN_TIMEOUT);
261 let max_body_bytes = overrides
262 .http_max_body_bytes
263 .unwrap_or(DEFAULT_MAX_BODY_BYTES);
264 let proxy = HttpProxyConfig::from_overrides(overrides)?;
265
266 Self::new(
267 bind_address,
268 request_timeout,
269 shutdown_timeout,
270 max_body_bytes,
271 proxy,
272 )
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
278pub enum HttpConfigError {
279 InvalidBindAddress { value: String },
280 EphemeralPort,
281 ZeroDuration { field: &'static str },
282 ZeroMaxBodyBytes,
283 InvalidProxyUrl,
284 EmptyNoProxyRule,
285}
286
287impl fmt::Display for HttpConfigError {
288 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
289 match self {
290 Self::InvalidBindAddress { value } => {
291 write!(formatter, "bind address '{value}' is not host:port")
292 }
293 Self::EphemeralPort => write!(formatter, "bind address must use an explicit port"),
294 Self::ZeroDuration { field } => write!(formatter, "{field} must be greater than zero"),
295 Self::ZeroMaxBodyBytes => write!(
296 formatter,
297 "max request body bytes must be greater than zero"
298 ),
299 Self::InvalidProxyUrl => write!(
300 formatter,
301 "proxy must use http:// or https:// and include a host"
302 ),
303 Self::EmptyNoProxyRule => write!(formatter, "no-proxy entries must not be empty"),
304 }
305 }
306}
307
308impl Error for HttpConfigError {}
309
310#[derive(Debug)]
312pub enum HttpServeError {
313 Bind(std::io::Error),
314 Serve(std::io::Error),
315 ShutdownTimeout,
316}
317
318impl fmt::Display for HttpServeError {
319 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
320 match self {
321 Self::Bind(error) => write!(formatter, "failed to bind HTTP listener: {error}"),
322 Self::Serve(error) => write!(formatter, "HTTP server failed: {error}"),
323 Self::ShutdownTimeout => write!(formatter, "HTTP graceful shutdown timed out"),
324 }
325 }
326}
327
328impl Error for HttpServeError {}
329
330#[derive(Debug)]
332pub enum HttpClientError {
333 InvalidUrl(String),
334 Io(io::Error),
335 Timeout,
336 InvalidResponse,
337 ResponseStatus(u16),
338 ResponseJson(serde_json::Error),
339}
340
341impl fmt::Display for HttpClientError {
342 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
343 match self {
344 Self::InvalidUrl(value) => write!(formatter, "invalid HTTP worker URL: {value}"),
345 Self::Io(error) => write!(formatter, "HTTP worker request failed: {error}"),
346 Self::Timeout => write!(formatter, "HTTP worker request timed out"),
347 Self::InvalidResponse => write!(formatter, "HTTP worker returned invalid response"),
348 Self::ResponseStatus(status) => {
349 write!(formatter, "HTTP worker returned status {status}")
350 }
351 Self::ResponseJson(error) => {
352 write!(formatter, "HTTP worker returned invalid JSON: {error}")
353 }
354 }
355 }
356}
357
358impl Error for HttpClientError {}
359
360pub async fn post_json(
362 config: &HttpConfig,
363 url: &str,
364 payload: &Value,
365) -> Result<Value, HttpClientError> {
366 let request = JsonHttpRequest::parse(url)?;
367 let body = serde_json::to_vec(payload).map_err(HttpClientError::ResponseJson)?;
368 let response = tokio::time::timeout(config.request_timeout, send_json_request(request, body))
369 .await
370 .map_err(|_| HttpClientError::Timeout)??;
371
372 serde_json::from_slice(&response).map_err(HttpClientError::ResponseJson)
373}
374
375struct JsonHttpRequest {
376 host: String,
377 port: u16,
378 path: String,
379}
380
381impl JsonHttpRequest {
382 fn parse(value: &str) -> Result<Self, HttpClientError> {
383 let remainder = value
384 .strip_prefix("http://")
385 .ok_or_else(|| HttpClientError::InvalidUrl(value.to_owned()))?;
386 let (authority, path) = remainder
387 .split_once('/')
388 .map_or((remainder, "/"), |(authority, path)| {
389 (authority, path.trim_start_matches('/'))
390 });
391 if authority.is_empty() {
392 return Err(HttpClientError::InvalidUrl(value.to_owned()));
393 }
394 let (host, port) = authority
395 .rsplit_once(':')
396 .map(|(host, port)| {
397 let parsed_port = port
398 .parse::<u16>()
399 .map_err(|_| HttpClientError::InvalidUrl(value.to_owned()))?;
400 Ok((host.to_owned(), parsed_port))
401 })
402 .unwrap_or_else(|| Ok((authority.to_owned(), 80)))?;
403 if host.is_empty() || port == 0 {
404 return Err(HttpClientError::InvalidUrl(value.to_owned()));
405 }
406 let path = if path.is_empty() {
407 "/".to_owned()
408 } else {
409 format!("/{path}")
410 };
411
412 Ok(Self { host, port, path })
413 }
414}
415
416async fn send_json_request(
417 request: JsonHttpRequest,
418 body: Vec<u8>,
419) -> Result<Vec<u8>, HttpClientError> {
420 let mut stream = tokio::net::TcpStream::connect((request.host.as_str(), request.port))
421 .await
422 .map_err(HttpClientError::Io)?;
423 let head = format!(
424 "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",
425 request.path,
426 request.host,
427 body.len()
428 );
429 stream
430 .write_all(head.as_bytes())
431 .await
432 .map_err(HttpClientError::Io)?;
433 stream.write_all(&body).await.map_err(HttpClientError::Io)?;
434 stream.shutdown().await.map_err(HttpClientError::Io)?;
435 let mut response = Vec::new();
436 stream
437 .read_to_end(&mut response)
438 .await
439 .map_err(HttpClientError::Io)?;
440 parse_http_response(response)
441}
442
443fn parse_http_response(response: Vec<u8>) -> Result<Vec<u8>, HttpClientError> {
444 let Some(header_end) = response.windows(4).position(|window| window == b"\r\n\r\n") else {
445 return Err(HttpClientError::InvalidResponse);
446 };
447 let headers = std::str::from_utf8(&response[..header_end])
448 .map_err(|_| HttpClientError::InvalidResponse)?;
449 let status = headers
450 .lines()
451 .next()
452 .and_then(|line| line.split_whitespace().nth(1))
453 .and_then(|value| value.parse::<u16>().ok())
454 .ok_or(HttpClientError::InvalidResponse)?;
455 if !(200..300).contains(&status) {
456 return Err(HttpClientError::ResponseStatus(status));
457 }
458
459 Ok(response[header_end + 4..].to_vec())
460}
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
464pub struct HttpConnectionId(u64);
465
466impl HttpConnectionId {
467 pub const fn get(self) -> u64 {
469 self.0
470 }
471}
472
473pub async fn serve_router(
475 router: Router,
476 config: HttpConfig,
477 shutdown: impl Future<Output = ()> + Send + 'static,
478) -> Result<(), HttpServeError> {
479 let listener = tokio::net::TcpListener::bind(config.bind_address.to_string())
480 .await
481 .map_err(HttpServeError::Bind)?;
482
483 serve_listener(listener, router, config, shutdown).await
484}
485
486pub async fn serve_router_with_qos(
488 router: Router,
489 config: HttpConfig,
490 qos: QosRuntime,
491 policy: QosPolicy,
492 shutdown: impl Future<Output = ()> + Send + 'static,
493) -> Result<(), HttpServeError> {
494 let listener = tokio::net::TcpListener::bind(config.bind_address.to_string())
495 .await
496 .map_err(HttpServeError::Bind)?;
497 let listener = QosTcpListener::new(listener, qos, policy);
498
499 serve_listener(listener, router, config, shutdown).await
500}
501
502async fn serve_listener<L>(
503 listener: L,
504 router: Router,
505 config: HttpConfig,
506 shutdown: impl Future<Output = ()> + Send + 'static,
507) -> Result<(), HttpServeError>
508where
509 L: Listener,
510 L::Addr: fmt::Debug,
511{
512 let (shutdown_started, mut shutdown_observed) = tokio::sync::watch::channel(false);
513 let graceful_shutdown = async move {
514 shutdown.await;
515 let _ = shutdown_started.send(true);
516 };
517 let server = axum::serve(
518 listener,
519 HttpMakeService::new(router, config.request_timeout),
520 )
521 .with_graceful_shutdown(graceful_shutdown)
522 .into_future();
523
524 tokio::pin!(server);
525 tokio::select! {
526 result = &mut server => result.map_err(HttpServeError::Serve),
527 changed = shutdown_observed.changed() => {
528 let _ = changed;
529 match tokio::time::timeout(config.graceful_shutdown_timeout, &mut server).await {
530 Ok(result) => result.map_err(HttpServeError::Serve),
531 Err(_) => Err(HttpServeError::ShutdownTimeout),
532 }
533 }
534 }
535}
536
537struct HttpMakeService {
538 router: Router,
539 request_timeout: Duration,
540 next_connection_id: Arc<AtomicU64>,
541}
542
543impl HttpMakeService {
544 fn new(router: Router, request_timeout: Duration) -> Self {
545 Self {
546 router,
547 request_timeout,
548 next_connection_id: Arc::new(AtomicU64::new(1)),
549 }
550 }
551}
552
553impl<'a, L> Service<IncomingStream<'a, L>> for HttpMakeService
554where
555 L: Listener,
556{
557 type Response = HttpConnectionService<Router>;
558 type Error = Infallible;
559 type Future = Ready<Result<Self::Response, Self::Error>>;
560
561 fn poll_ready(&mut self, _context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
562 Poll::Ready(Ok(()))
563 }
564
565 fn call(&mut self, _target: IncomingStream<'a, L>) -> Self::Future {
566 let connection_id =
567 HttpConnectionId(self.next_connection_id.fetch_add(1, Ordering::Relaxed));
568 ready(Ok(HttpConnectionService::new(
569 self.router.clone(),
570 connection_id,
571 self.request_timeout,
572 )))
573 }
574}
575
576struct HttpConnectionService<S> {
577 inner: S,
578 connection_id: HttpConnectionId,
579 request_timeout: Duration,
580}
581
582impl<S> HttpConnectionService<S> {
583 fn new(inner: S, connection_id: HttpConnectionId, request_timeout: Duration) -> Self {
584 Self {
585 inner,
586 connection_id,
587 request_timeout,
588 }
589 }
590}
591
592impl<S> Clone for HttpConnectionService<S>
593where
594 S: Clone,
595{
596 fn clone(&self) -> Self {
597 Self {
598 inner: self.inner.clone(),
599 connection_id: self.connection_id,
600 request_timeout: self.request_timeout,
601 }
602 }
603}
604
605impl<S> Service<Request> for HttpConnectionService<S>
606where
607 S: Service<Request, Response = Response, Error = Infallible> + Send,
608 S::Future: Send + 'static,
609{
610 type Response = Response;
611 type Error = Infallible;
612 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
613
614 fn poll_ready(&mut self, context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
615 self.inner.poll_ready(context)
616 }
617
618 fn call(&mut self, mut request: Request) -> Self::Future {
619 request.extensions_mut().insert(self.connection_id);
620 let future = self.inner.call(request);
621 let request_timeout = self.request_timeout;
622 Box::pin(async move {
623 match tokio::time::timeout(request_timeout, future).await {
624 Ok(result) => result,
625 Err(_) => Ok((StatusCode::REQUEST_TIMEOUT, "request timed out").into_response()),
626 }
627 })
628 }
629}
630
631struct QosTcpListener {
632 inner: tokio::net::TcpListener,
633 qos: QosRuntime,
634 policy: QosPolicy,
635}
636
637impl QosTcpListener {
638 fn new(inner: tokio::net::TcpListener, qos: QosRuntime, policy: QosPolicy) -> Self {
639 Self { inner, qos, policy }
640 }
641}
642
643impl Listener for QosTcpListener {
644 type Io = QosTcpStream;
645 type Addr = std::net::SocketAddr;
646
647 async fn accept(&mut self) -> (Self::Io, Self::Addr) {
648 loop {
649 match self.inner.accept().await {
650 Ok((stream, address)) => match self.qos.admit_connection(&self.policy) {
651 Ok(permit) => {
652 return (
653 QosTcpStream {
654 inner: stream,
655 _permit: permit,
656 },
657 address,
658 );
659 }
660 Err(RejectReason::ConnectionBudgetExceeded) => drop(stream),
661 Err(_) => drop(stream),
662 },
663 Err(_) => tokio::time::sleep(Duration::from_secs(1)).await,
664 }
665 }
666 }
667
668 fn local_addr(&self) -> io::Result<Self::Addr> {
669 self.inner.local_addr()
670 }
671}
672
673struct QosTcpStream {
674 inner: tokio::net::TcpStream,
675 _permit: QosPermit,
676}
677
678impl AsyncRead for QosTcpStream {
679 fn poll_read(
680 mut self: Pin<&mut Self>,
681 context: &mut Context<'_>,
682 buffer: &mut ReadBuf<'_>,
683 ) -> Poll<io::Result<()>> {
684 Pin::new(&mut self.inner).poll_read(context, buffer)
685 }
686}
687
688impl AsyncWrite for QosTcpStream {
689 fn poll_write(
690 mut self: Pin<&mut Self>,
691 context: &mut Context<'_>,
692 buffer: &[u8],
693 ) -> Poll<io::Result<usize>> {
694 Pin::new(&mut self.inner).poll_write(context, buffer)
695 }
696
697 fn poll_flush(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<io::Result<()>> {
698 Pin::new(&mut self.inner).poll_flush(context)
699 }
700
701 fn poll_shutdown(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<io::Result<()>> {
702 Pin::new(&mut self.inner).poll_shutdown(context)
703 }
704}
705
706fn validate_proxy_url(value: &str) -> Result<(), HttpConfigError> {
707 let Some((scheme, remainder)) = value.split_once("://") else {
708 return Err(HttpConfigError::InvalidProxyUrl);
709 };
710
711 if !matches!(scheme, "http" | "https") {
712 return Err(HttpConfigError::InvalidProxyUrl);
713 }
714
715 let authority = remainder.split('/').next().unwrap_or_default();
716 if authority.is_empty() || authority.starts_with('@') {
717 return Err(HttpConfigError::InvalidProxyUrl);
718 }
719 let host_port = authority
720 .rsplit_once('@')
721 .map_or(authority, |(_, host)| host);
722 let host = if let Some(remainder) = host_port.strip_prefix('[') {
723 remainder.split_once(']').map_or("", |(host, _)| host)
724 } else {
725 host_port.split(':').next().unwrap_or_default()
726 };
727 if host.is_empty() {
728 return Err(HttpConfigError::InvalidProxyUrl);
729 }
730
731 Ok(())
732}
733
734fn parse_no_proxy_rules(value: Option<&str>) -> Result<Vec<String>, HttpConfigError> {
735 value
736 .map(|rules| {
737 rules
738 .split(',')
739 .map(str::trim)
740 .map(|rule| {
741 if rule.is_empty() {
742 Err(HttpConfigError::EmptyNoProxyRule)
743 } else {
744 Ok(rule.to_owned())
745 }
746 })
747 .collect()
748 })
749 .unwrap_or_else(|| Ok(Vec::new()))
750}
751
752fn is_local_bind(bind: &str) -> bool {
753 is_loopback_host(authority_host(bind))
754}
755
756fn authority_host(authority: &str) -> &str {
757 if let Some(remainder) = authority.strip_prefix('[') {
758 return remainder
759 .find(']')
760 .map_or(authority, |index| &remainder[..index]);
761 }
762
763 authority
764 .rsplit_once(':')
765 .map_or(authority, |(host, _)| host)
766}
767
768fn is_loopback_host(host: &str) -> bool {
769 host.eq_ignore_ascii_case("localhost")
770 || host
771 .parse::<IpAddr>()
772 .is_ok_and(|address| address.is_loopback())
773}
774
775#[cfg(test)]
776#[path = "http_tests.rs"]
777mod tests;