Skip to main content

opys_dev/
http.rs

1//! Build-time HTTP.
2//!
3//! One blocking GET against a shared agent. No retry, no streaming, no
4//! resume, no download bookkeeping — deliberately. Resolvers run once, at
5//! build time, against small JSON APIs; the install path has its own
6//! downloader in `opys-runtime` and must never reach for this.
7
8use std::sync::OnceLock;
9
10/// A completed response. The body is read eagerly: build-time payloads are
11/// JSON documents measured in kilobytes, so there is nothing to stream.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct HttpResponse {
14    pub status: u16,
15    pub body: String,
16}
17
18impl HttpResponse {
19    /// 2xx. A non-2xx status is data, not an error — resolvers soft-skip a
20    /// 404 for a platform a release doesn't ship.
21    pub fn ok(&self) -> bool {
22        (200..300).contains(&self.status)
23    }
24}
25
26/// Transport failure — DNS, TLS, connect, or a body that isn't UTF-8. An
27/// HTTP status is *not* one of these; it comes back in [`HttpResponse`].
28#[derive(Debug, thiserror::Error)]
29#[error("request to {url} failed: {reason}")]
30pub struct HttpError {
31    pub url: String,
32    pub reason: String,
33}
34
35/// Identify ourselves: the bare agent string gets rejected or rate-limited by
36/// some CDNs and by the CurseForge API. The patch component is omitted so the
37/// string doesn't churn against the version on every release. Mirrors
38/// `@opys/core`'s `OPYS_USER_AGENT`.
39pub const OPYS_USER_AGENT: &str = "opys/1.0";
40
41/// Ceiling on a response body. GitHub's `releases?per_page=100` is the
42/// largest document any resolver asks for and lands well under this; the cap
43/// exists so a misrouted request can't exhaust memory.
44const BODY_LIMIT: u64 = 32 * 1024 * 1024;
45
46/// One agent for the whole build — it owns the connection pool, so sharing it
47/// is what lets the per-platform fan-out reuse TLS sessions.
48fn agent() -> &'static ureq::Agent {
49    static AGENT: OnceLock<ureq::Agent> = OnceLock::new();
50    AGENT.get_or_init(|| {
51        ureq::Agent::config_builder()
52            .user_agent(OPYS_USER_AGENT)
53            // A 404 is an answer, not a transport failure — resolvers branch
54            // on the status themselves.
55            .http_status_as_error(false)
56            .build()
57            .into()
58    })
59}
60
61fn call(
62    url: &str,
63    headers: &[(&str, &str)],
64) -> Result<ureq::http::Response<ureq::Body>, HttpError> {
65    let mut req = agent().get(url);
66    for (name, value) in headers {
67        req = req.header(*name, *value);
68    }
69    req.call().map_err(|e| HttpError {
70        url: url.to_owned(),
71        reason: e.to_string(),
72    })
73}
74
75/// GET `url` and read the whole body. Blocking: build-time resolvers are
76/// plain synchronous functions, parallelised with `std::thread::scope` when
77/// they need it.
78pub fn get(url: &str, headers: &[(&str, &str)]) -> Result<HttpResponse, HttpError> {
79    let mut res = call(url, headers)?;
80    let status = res.status().as_u16();
81    let body = res
82        .body_mut()
83        .with_config()
84        .limit(BODY_LIMIT)
85        .read_to_string()
86        .map_err(|e| HttpError {
87            url: url.to_owned(),
88            reason: e.to_string(),
89        })?;
90    Ok(HttpResponse { status, body })
91}
92
93/// POST `body` as JSON and read the whole response.
94///
95/// For the one API here that takes its query as a document rather than as a
96/// URL — CurseForge's batched file lookup. Like [`get`], a status is data.
97pub fn post_json(
98    url: &str,
99    headers: &[(&str, &str)],
100    body: &serde_json::Value,
101) -> Result<HttpResponse, HttpError> {
102    let fail = |e: &dyn std::fmt::Display| HttpError {
103        url: url.to_owned(),
104        reason: e.to_string(),
105    };
106    let mut req = agent().post(url).header("content-type", "application/json");
107    for (name, value) in headers {
108        req = req.header(*name, *value);
109    }
110    let mut res = req.send(body.to_string()).map_err(|e| fail(&e))?;
111    let status = res.status().as_u16();
112    let body = res
113        .body_mut()
114        .with_config()
115        .limit(BODY_LIMIT)
116        .read_to_string()
117        .map_err(|e| fail(&e))?;
118    Ok(HttpResponse { status, body })
119}
120
121/// A response whose body is not text.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct HttpBytes {
124    pub status: u16,
125    pub body: Vec<u8>,
126}
127
128impl HttpBytes {
129    /// 2xx.
130    pub fn ok(&self) -> bool {
131        (200..300).contains(&self.status)
132    }
133}
134
135/// Ceiling on a binary body. The one thing fetched this way is a modpack
136/// archive, which has to be opened at build time to learn what the pack is
137/// made of; the largest ones run to a few hundred megabytes.
138const BYTES_LIMIT: u64 = 2 * 1024 * 1024 * 1024;
139
140/// GET `url` and read the whole body as bytes.
141///
142/// Still one blocking request with no retry and no resume, and still not a
143/// downloader: it exists for the rare resolver that must *read* a file to
144/// resolve it. Installing files is `opys-runtime`'s job.
145pub fn get_bytes(url: &str, headers: &[(&str, &str)]) -> Result<HttpBytes, HttpError> {
146    let mut res = call(url, headers)?;
147    let status = res.status().as_u16();
148    let body = res
149        .body_mut()
150        .with_config()
151        .limit(BYTES_LIMIT)
152        .read_to_vec()
153        .map_err(|e| HttpError {
154            url: url.to_owned(),
155            reason: e.to_string(),
156        })?;
157    Ok(HttpBytes { status, body })
158}
159
160/// A build-time JSON GET that failed: the transport did, the server answered
161/// with a non-2xx, or the body was not the document expected.
162///
163/// Kept apart from [`HttpError`] because [`get`] deliberately treats a status
164/// as data — a resolver soft-skips a 404 for a platform a release doesn't
165/// ship. [`get_json`] is the other half of that split: the callers who want a
166/// document and have nothing to do with a status that isn't 200.
167#[derive(Debug, thiserror::Error)]
168pub enum JsonGetError {
169    #[error(transparent)]
170    Transport(#[from] HttpError),
171    #[error("{url} returned HTTP {status}")]
172    Status { url: String, status: u16 },
173    #[error(transparent)]
174    Decode(#[from] serde_json::Error),
175}
176
177/// GET `url` and parse the body as `T`.
178///
179/// Every loader resolver starts here — a small JSON API, one request, no
180/// retry. Shared so that the status check and the decode have one spelling
181/// across the family rather than one per crate.
182pub fn get_json<T: serde::de::DeserializeOwned>(
183    url: &str,
184    headers: &[(&str, &str)],
185) -> Result<T, JsonGetError> {
186    let response = get(url, headers)?;
187    if !response.ok() {
188        return Err(JsonGetError::Status {
189            url: url.to_owned(),
190            status: response.status,
191        });
192    }
193    Ok(serde_json::from_str(&response.body)?)
194}