Skip to main content

speccoder_runtime/github/
http.rs

1//! GitHub's documented REST and GraphQL APIs, over rustls, within a time
2//! limit, every failure said in words with what to do. Blocking for the
3//! caller (the runtime's commands are); one small runtime per call.
4
5use serde_json::Value;
6
7use super::auth::{SCOPES, Secret};
8
9/// How long one call may take.
10const LIMIT: std::time::Duration = std::time::Duration::from_secs(30);
11
12fn client() -> Result<reqwest::Client, String> {
13    reqwest::Client::builder()
14        .user_agent(concat!("speccoder/", env!("CARGO_PKG_VERSION")))
15        .connect_timeout(std::time::Duration::from_secs(10))
16        .timeout(LIMIT)
17        .build()
18        .map_err(|e| e.to_string())
19}
20
21fn block<T>(f: impl std::future::Future<Output = Result<T, String>>) -> Result<T, String> {
22    crate::pipeline::tokio_runtime()?.block_on(f)
23}
24
25/// Where a host's APIs answer: `api.github.com` for github.com, `/api/v3`
26/// on an Enterprise server; `base` replaces both (tests, a proxy).
27#[must_use]
28pub fn endpoints(host: &str, base: Option<&str>) -> (String, String) {
29    if let Some(base) = base.filter(|b| !b.trim().is_empty()) {
30        let base = base.trim_end_matches('/').to_owned();
31        return (base.clone(), format!("{base}/graphql"));
32    }
33    if host == "github.com" {
34        (
35            "https://api.github.com".to_owned(),
36            "https://api.github.com/graphql".to_owned(),
37        )
38    } else {
39        (
40            format!("https://{host}/api/v3"),
41            format!("https://{host}/api/graphql"),
42        )
43    }
44}
45
46/// Where a host's OAuth device flow lives.
47#[must_use]
48pub fn oauth(host: &str) -> String {
49    std::env::var("SPECCODER_GITHUB_OAUTH")
50        .ok()
51        .filter(|b| !b.trim().is_empty())
52        .unwrap_or_else(|| format!("https://{host}"))
53        .trim_end_matches('/')
54        .to_owned()
55}
56
57/// A signed-in conversation with one host.
58#[derive(Debug, Clone)]
59pub struct Api {
60    host: String,
61    rest: String,
62    graphql: String,
63    token: Secret,
64}
65
66impl Api {
67    /// Speaks to `host` as `token`, at `base` when given.
68    #[must_use]
69    pub fn new(host: &str, token: Secret, base: Option<&str>) -> Self {
70        let (rest, graphql) = endpoints(host, base);
71        Self {
72            host: host.to_owned(),
73            rest,
74            graphql,
75            token,
76        }
77    }
78
79    fn request(
80        &self,
81        method: reqwest::Method,
82        url: &str,
83    ) -> Result<reqwest::RequestBuilder, String> {
84        Ok(client()?
85            .request(method, url)
86            .bearer_auth(self.token.expose())
87            .header(reqwest::header::ACCEPT, "application/vnd.github+json")
88            .header("X-GitHub-Api-Version", "2022-11-28"))
89    }
90
91    /// One REST call; `path` starts with `/`.
92    ///
93    /// # Errors
94    ///
95    /// The host's refusal, in words.
96    pub fn rest(
97        &self,
98        method: reqwest::Method,
99        path: &str,
100        body: Option<&Value>,
101    ) -> Result<Value, String> {
102        let url = format!("{}{path}", self.rest);
103        let mut req = self.request(method, &url)?;
104        if let Some(b) = body {
105            req = req.json(b);
106        }
107        block(async move {
108            let resp = req.send().await.map_err(|e| unreachable(&self.host, &e))?;
109            answer(&self.host, path, resp).await
110        })
111    }
112
113    /// `GET path`.
114    ///
115    /// # Errors
116    ///
117    /// As [`Api::rest`].
118    pub fn get(&self, path: &str) -> Result<Value, String> {
119        self.rest(reqwest::Method::GET, path, None)
120    }
121
122    /// `GET path` as a unified diff (a pull request's `.diff` media type):
123    /// read-only, never a checkout.
124    ///
125    /// # Errors
126    ///
127    /// As [`Api::rest`].
128    pub fn get_diff(&self, path: &str) -> Result<String, String> {
129        let url = format!("{}{path}", self.rest);
130        let req = self
131            .request(reqwest::Method::GET, &url)?
132            .header(reqwest::header::ACCEPT, "application/vnd.github.diff");
133        let body = block(async move {
134            let resp = req.send().await.map_err(|e| unreachable(&self.host, &e))?;
135            answer(&self.host, path, resp).await
136        })?;
137        Ok(match body {
138            Value::String(s) => s,
139            Value::Null => String::new(),
140            other => other.to_string(),
141        })
142    }
143
144    /// One GraphQL document: its `data`.
145    ///
146    /// # Errors
147    ///
148    /// The host's refusal, or the first GraphQL error.
149    pub fn graphql(&self, query: &str, variables: Value) -> Result<Value, String> {
150        let req = self
151            .request(reqwest::Method::POST, &self.graphql)?
152            .json(&serde_json::json!({ "query": query, "variables": variables }));
153        let body = block(async move {
154            let resp = req.send().await.map_err(|e| unreachable(&self.host, &e))?;
155            answer(&self.host, "graphql", resp).await
156        })?;
157        if let Some(e) = body["errors"].as_array().and_then(|e| e.first()) {
158            return Err(format!(
159                "GitHub: {}",
160                e["message"].as_str().unwrap_or("the query failed")
161            ));
162        }
163        Ok(body["data"].clone())
164    }
165}
166
167/// A form POST answered in JSON (the OAuth endpoints).
168pub(crate) fn form(url: &str, fields: &[(&str, &str)]) -> Result<Value, String> {
169    let req = client()?
170        .post(url)
171        .header(reqwest::header::ACCEPT, "application/json")
172        .form(fields);
173    block(async move {
174        let resp = req.send().await.map_err(|e| format!("{url}: {e}"))?;
175        resp.json::<Value>()
176            .await
177            .map_err(|e| format!("{url}: {e}"))
178    })
179}
180
181fn unreachable(host: &str, e: &reqwest::Error) -> String {
182    if e.is_timeout() {
183        format!("{host} did not answer within {}s", LIMIT.as_secs())
184    } else {
185        format!("{host} could not be reached: {e}")
186    }
187}
188
189/// The body of a 2xx; anything else as words with what to do.
190async fn answer(host: &str, what: &str, resp: reqwest::Response) -> Result<Value, String> {
191    let status = resp.status();
192    let remaining = resp
193        .headers()
194        .get("x-ratelimit-remaining")
195        .and_then(|v| v.to_str().ok())
196        .map(str::to_owned);
197    let reset = resp
198        .headers()
199        .get("x-ratelimit-reset")
200        .and_then(|v| v.to_str().ok())
201        .and_then(|v| v.parse::<u64>().ok());
202    let text = resp.text().await.unwrap_or_default();
203    let body: Value = if text.trim().is_empty() {
204        Value::Null
205    } else {
206        serde_json::from_str(&text).unwrap_or(Value::String(text))
207    };
208    if status.is_success() {
209        return Ok(body);
210    }
211    let says = body["message"].as_str().unwrap_or_default().to_owned();
212    Err(match status.as_u16() {
213        401 => format!("{host} refused the token (expired or revoked): `speccoder auth login`"),
214        403 | 429 if remaining.as_deref() == Some("0") => {
215            let now = std::time::SystemTime::now()
216                .duration_since(std::time::UNIX_EPOCH)
217                .map_or(0, |d| d.as_secs());
218            let wait = reset.map_or(60, |r| r.saturating_sub(now));
219            format!(
220                "{host}'s rate limit is used up; it resets in {} min",
221                wait.div_ceil(60).max(1)
222            )
223        }
224        403 => format!(
225            "the token may not do this ({says}); it needs `{SCOPES}`, or for a fine-grained token: Issues read, Pull requests read and write, Contents and Metadata read"
226        ),
227        404 if host == "github.com" => format!(
228            "{what} was not found on {host}: the repository, or the token cannot see it — signed in with `speccoder auth login`? Install the {} app on the repository: {}",
229            super::auth::APP_SLUG,
230            super::auth::install_url()
231        ),
232        404 => {
233            format!("{what} was not found on {host}: the repository, or the token cannot see it")
234        }
235        422 => {
236            let detail: Vec<String> = body["errors"]
237                .as_array()
238                .into_iter()
239                .flatten()
240                .filter_map(|e| e["message"].as_str().map(str::to_owned))
241                .collect();
242            if detail.is_empty() {
243                format!("GitHub refused it: {says}")
244            } else {
245                format!("GitHub refused it: {says} ({})", detail.join("; "))
246            }
247        }
248        code => format!("GitHub answered {code}: {says}"),
249    })
250}