Skip to main content

ephemeris_forge_github/api/
http.rs

1//! Authenticated GitHub REST transport and wire conversion.
2
3mod access;
4mod actions;
5mod autolinks;
6mod custom_properties;
7mod environments;
8mod labels;
9mod pages;
10mod release;
11mod repository;
12mod rulesets;
13mod security;
14mod variables;
15mod webhooks;
16
17use std::time::Duration;
18
19use ephemeris_forge::{Http, RetryPolicy, TransportBuildError};
20use ephemeris_token::Token;
21use facet::Facet;
22use reqwest::header::{ACCEPT, HeaderName, HeaderValue};
23use reqwest::{Method, Url};
24
25use super::error::ForgeError;
26
27const PAGE_SIZE: usize = 100;
28
29/// GitHub's HTTP implementation of [`super::Api`].
30///
31/// `Debug` is derived, which stays safe because the transport's token redacts
32/// itself.
33#[derive(Clone, Debug)]
34pub struct HttpApi {
35    pub(crate) http: Http,
36}
37
38impl HttpApi {
39    /// Create a GitHub REST client rooted at `base` (normally
40    /// `https://api.github.com`).
41    ///
42    /// # Errors
43    /// Returns an error if the underlying HTTP client fails to build.
44    pub fn new(base: &Url, token: Token) -> Result<Self, TransportBuildError> {
45        let http = Http::new(base.clone(), token, Duration::from_secs(30))?
46            .with_header(
47                ACCEPT,
48                HeaderValue::from_static("application/vnd.github+json"),
49            )
50            .with_header(
51                HeaderName::from_static("x-github-api-version"),
52                HeaderValue::from_static("2026-03-10"),
53            );
54        Ok(Self { http })
55    }
56
57    /// Replace the retry policy, which keeps tests from sleeping.
58    #[must_use]
59    pub fn with_retry(mut self, retry: RetryPolicy) -> Self {
60        self.http = self.http.with_retry(retry);
61        self
62    }
63
64    async fn send<T: for<'a> Facet<'a>>(
65        &self,
66        method: Method,
67        path: &str,
68        body: Option<String>,
69    ) -> Result<T, ForgeError> {
70        let raw = body.as_deref();
71        Ok(self.http.json(method, path, raw).await?)
72    }
73
74    /// A `GET` that reads a missing resource as `None`.
75    async fn optional<T: for<'a> Facet<'a>>(&self, path: &str) -> Result<Option<T>, ForgeError> {
76        Ok(self.http.json_optional(Method::GET, path, None).await?)
77    }
78
79    async fn empty(
80        &self,
81        method: Method,
82        path: &str,
83        body: Option<String>,
84    ) -> Result<(), ForgeError> {
85        let raw = body.as_deref();
86        self.http.send(method, path, raw).await?;
87        Ok(())
88    }
89
90    async fn paged<T: for<'a> Facet<'a>>(&self, path: &str) -> Result<Vec<T>, ForgeError> {
91        self.paged_in(path, PAGE_SIZE, |values: Vec<T>| values)
92            .await
93    }
94
95    async fn paged_in<R: for<'a> Facet<'a>, T>(
96        &self,
97        path: &str,
98        page_size: usize,
99        items: fn(R) -> Vec<T>,
100    ) -> Result<Vec<T>, ForgeError> {
101        Ok(self
102            .http
103            .paged_in(
104                page_size,
105                path,
106                |page| format!("per_page={page_size}&page={page}"),
107                items,
108            )
109            .await?)
110    }
111}