Skip to main content

anodizer_core/
http.rs

1//! HTTP client helpers shared by every stage that talks to a remote.
2//!
3//! All anodizer HTTP traffic should go through `blocking_client(...)` so that
4//! the `User-Agent`, default-roots, and timeout policy stay consistent across
5//! publishers, announcers, and the release backends.
6
7use std::time::Duration;
8
9use anyhow::{Context as _, Result};
10
11/// Canonical user-agent string sent with every anodizer HTTP request.
12///
13/// Versioning the UA matters for upstream services that rate-limit or
14/// fingerprint by client identity (Discourse, Reddit, GitHub, etc.).
15pub const USER_AGENT: &str = concat!("anodizer/", env!("CARGO_PKG_VERSION"));
16
17/// Build a blocking `reqwest::Client` configured with the canonical UA,
18/// the requested per-request timeout, and the platform's built-in roots.
19pub fn blocking_client(timeout: Duration) -> Result<reqwest::blocking::Client> {
20    reqwest::blocking::Client::builder()
21        .user_agent(USER_AGENT)
22        .timeout(timeout)
23        .build()
24        .context("build blocking HTTP client")
25}
26
27/// Async equivalent of `blocking_client`.
28pub fn async_client(timeout: Duration) -> Result<reqwest::Client> {
29    reqwest::Client::builder()
30        .user_agent(USER_AGENT)
31        .timeout(timeout)
32        .build()
33        .context("build async HTTP client")
34}
35
36/// Resolve the GitHub REST API base URL through an injected env source.
37///
38/// Honors the undocumented `ANODIZER_GITHUB_API_BASE` override so unit tests
39/// can redirect GitHub REST calls to an in-process responder via a
40/// [`MapEnvSource`](crate::MapEnvSource); defaults to the canonical
41/// `https://api.github.com` in production where callers pass
42/// [`ProcessEnvSource`](crate::ProcessEnvSource) and the var is unset. Any
43/// trailing `/` is stripped so callers can unconditionally `format!` with a
44/// `/`-prefixed suffix without producing a double slash.
45///
46/// Every GitHub REST caller (release-stage rate-limit polls, publish-stage
47/// default-branch / PR lookups) must resolve its base through this one
48/// helper so a single override redirects the whole run to the same host.
49pub fn github_api_base<E: crate::EnvSource + ?Sized>(env: &E) -> String {
50    let raw = env
51        .var("ANODIZER_GITHUB_API_BASE")
52        .unwrap_or_else(|| "https://api.github.com".to_string());
53    raw.trim_end_matches('/').to_string()
54}
55
56/// Like [`github_api_base`], but honoring a configured `github_urls.api`
57/// (GitHub Enterprise Server) first.
58///
59/// Preflight probes and milestone operations must contact the same host the
60/// release backend will (see `build_octocrab_client`): probing github.com
61/// for a repo that lives on a GHES host false-404s (Blocker for a release
62/// that would succeed), or worse, returns a verdict for an unrelated
63/// same-named public repo. Precedence: `github_urls.api` >
64/// `ANODIZER_GITHUB_API_BASE` > `https://api.github.com`. Any trailing `/`
65/// is stripped, as in [`github_api_base`].
66pub fn github_api_base_with_config<E: crate::EnvSource + ?Sized>(
67    github_urls: Option<&crate::config::GitHubUrlsConfig>,
68    env: &E,
69) -> String {
70    github_urls
71        .and_then(|u| u.api.as_deref())
72        .map(|api| api.trim_end_matches('/').to_string())
73        .unwrap_or_else(|| github_api_base(env))
74}
75
76/// Format an HTTP body-read failure as a descriptive placeholder string.
77///
78/// Used by [`body_of`] / [`body_of_blocking`]: a transport-level
79/// read error becomes `"could not read response body: <err>"` rather than
80/// silently truncating to `""`. Exposed as a free function so unit tests can
81/// pin the exact wording without standing up a fault-injecting HTTP server.
82pub fn body_read_error_message<E: std::fmt::Display>(err: E) -> String {
83    format!("could not read response body: {err}")
84}
85
86/// Read an HTTP response body to a `String`, returning a descriptive
87/// placeholder on read failure.
88///
89/// Reads and scrubs an HTTP response body for error reporting after
90/// commit `8b77358`: a transport-level read error becomes
91/// `"could not read response body: <err>"` rather than silently truncating
92/// to an empty string. Callers typically pass the resulting text into a
93/// larger error context (e.g. `"GitHub API returned 422: {body}"`), so the
94/// placeholder still surfaces a usable diagnostic instead of a confusing
95/// empty payload.
96///
97/// Use this when the body will be interpolated into a downstream error
98/// message; use `resp.text().await?` directly when the caller will
99/// propagate the read failure as its own error rather than substituting
100/// a placeholder.
101pub async fn body_of(resp: reqwest::Response) -> String {
102    match resp.text().await {
103        Ok(s) => s,
104        Err(err) => body_read_error_message(err),
105    }
106}
107
108/// Blocking analogue of [`body_of`].
109///
110/// Use this when the body will be interpolated into a downstream error
111/// message; use `resp.text()?` directly when the caller will propagate
112/// the read failure as its own error rather than substituting a
113/// placeholder.
114pub fn body_of_blocking(resp: reqwest::blocking::Response) -> String {
115    match resp.text() {
116        Ok(s) => s,
117        Err(err) => body_read_error_message(err),
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn github_api_base_strips_trailing_slash() {
127        let env =
128            crate::MapEnvSource::new().with("ANODIZER_GITHUB_API_BASE", "https://example.com/api/");
129        assert_eq!(github_api_base(&env), "https://example.com/api");
130    }
131
132    #[test]
133    fn github_api_base_defaults_when_env_unset() {
134        let env = crate::MapEnvSource::new();
135        assert_eq!(github_api_base(&env), "https://api.github.com");
136    }
137
138    #[test]
139    fn github_api_base_with_config_prefers_configured_ghes_api() {
140        let urls = crate::config::GitHubUrlsConfig {
141            api: Some("https://github.example.com/api/v3/".to_string()),
142            ..Default::default()
143        };
144        let env =
145            crate::MapEnvSource::new().with("ANODIZER_GITHUB_API_BASE", "https://override.test");
146        assert_eq!(
147            github_api_base_with_config(Some(&urls), &env),
148            "https://github.example.com/api/v3"
149        );
150    }
151
152    #[test]
153    fn github_api_base_with_config_falls_back_to_env_resolver() {
154        let env =
155            crate::MapEnvSource::new().with("ANODIZER_GITHUB_API_BASE", "https://override.test/");
156        assert_eq!(
157            github_api_base_with_config(None, &env),
158            "https://override.test"
159        );
160        let unset = crate::MapEnvSource::new();
161        assert_eq!(
162            github_api_base_with_config(None, &unset),
163            "https://api.github.com"
164        );
165    }
166
167    #[test]
168    fn test_body_read_error_message_uses_descriptive_prefix() {
169        // Pin the exact wording: callers may parse / match on this string,
170        // and the error-body contract requires the
171        // `"could not read response body: "` prefix verbatim.
172        let formatted = body_read_error_message("connection reset by peer");
173        assert_eq!(
174            formatted,
175            "could not read response body: connection reset by peer"
176        );
177    }
178
179    #[test]
180    fn test_body_read_error_message_with_io_error() {
181        let io_err = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "stream ended early");
182        let formatted = body_read_error_message(io_err);
183        assert!(
184            formatted.starts_with("could not read response body: "),
185            "format must keep the descriptive prefix: {formatted}"
186        );
187        assert!(
188            formatted.contains("stream ended early"),
189            "format must include the underlying error: {formatted}"
190        );
191    }
192}