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}