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    /// One GraphQL document: its `data`.
123    ///
124    /// # Errors
125    ///
126    /// The host's refusal, or the first GraphQL error.
127    pub fn graphql(&self, query: &str, variables: Value) -> Result<Value, String> {
128        let req = self
129            .request(reqwest::Method::POST, &self.graphql)?
130            .json(&serde_json::json!({ "query": query, "variables": variables }));
131        let body = block(async move {
132            let resp = req.send().await.map_err(|e| unreachable(&self.host, &e))?;
133            answer(&self.host, "graphql", resp).await
134        })?;
135        if let Some(e) = body["errors"].as_array().and_then(|e| e.first()) {
136            return Err(format!(
137                "GitHub: {}",
138                e["message"].as_str().unwrap_or("the query failed")
139            ));
140        }
141        Ok(body["data"].clone())
142    }
143}
144
145/// A form POST answered in JSON (the OAuth endpoints).
146pub(crate) fn form(url: &str, fields: &[(&str, &str)]) -> Result<Value, String> {
147    let req = client()?
148        .post(url)
149        .header(reqwest::header::ACCEPT, "application/json")
150        .form(fields);
151    block(async move {
152        let resp = req.send().await.map_err(|e| format!("{url}: {e}"))?;
153        resp.json::<Value>()
154            .await
155            .map_err(|e| format!("{url}: {e}"))
156    })
157}
158
159fn unreachable(host: &str, e: &reqwest::Error) -> String {
160    if e.is_timeout() {
161        format!("{host} did not answer within {}s", LIMIT.as_secs())
162    } else {
163        format!("{host} could not be reached: {e}")
164    }
165}
166
167/// The body of a 2xx; anything else as words with what to do.
168async fn answer(host: &str, what: &str, resp: reqwest::Response) -> Result<Value, String> {
169    let status = resp.status();
170    let remaining = resp
171        .headers()
172        .get("x-ratelimit-remaining")
173        .and_then(|v| v.to_str().ok())
174        .map(str::to_owned);
175    let reset = resp
176        .headers()
177        .get("x-ratelimit-reset")
178        .and_then(|v| v.to_str().ok())
179        .and_then(|v| v.parse::<u64>().ok());
180    let text = resp.text().await.unwrap_or_default();
181    let body: Value = if text.trim().is_empty() {
182        Value::Null
183    } else {
184        serde_json::from_str(&text).unwrap_or(Value::String(text))
185    };
186    if status.is_success() {
187        return Ok(body);
188    }
189    let says = body["message"].as_str().unwrap_or_default().to_owned();
190    Err(match status.as_u16() {
191        401 => format!("{host} refused the token (expired or revoked): `speccoder auth login`"),
192        403 | 429 if remaining.as_deref() == Some("0") => {
193            let now = std::time::SystemTime::now()
194                .duration_since(std::time::UNIX_EPOCH)
195                .map_or(0, |d| d.as_secs());
196            let wait = reset.map_or(60, |r| r.saturating_sub(now));
197            format!(
198                "{host}'s rate limit is used up; it resets in {} min",
199                wait.div_ceil(60).max(1)
200            )
201        }
202        403 => format!(
203            "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"
204        ),
205        404 if host == "github.com" => format!(
206            "{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: {}",
207            super::auth::APP_SLUG,
208            super::auth::install_url()
209        ),
210        404 => {
211            format!("{what} was not found on {host}: the repository, or the token cannot see it")
212        }
213        422 => {
214            let detail: Vec<String> = body["errors"]
215                .as_array()
216                .into_iter()
217                .flatten()
218                .filter_map(|e| e["message"].as_str().map(str::to_owned))
219                .collect();
220            if detail.is_empty() {
221                format!("GitHub refused it: {says}")
222            } else {
223                format!("GitHub refused it: {says} ({})", detail.join("; "))
224            }
225        }
226        code => format!("GitHub answered {code}: {says}"),
227    })
228}