use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use reqwest::{Method, Response, StatusCode, header};
use serde::de::DeserializeOwned;
use serde_json::Value;
use url::Url;
use vgi_forge::{ForgeError, Result};
use zeroize::Zeroizing;
use crate::secret::Secret;
const PAGE_LIMIT: usize = 50;
const MAX_PAGES: usize = 100;
#[derive(Clone, Copy)]
pub(crate) enum Auth<'a> {
Token(&'a Secret),
Bearer(&'a Secret),
Basic {
user: &'a str,
password: &'a Secret,
},
}
#[derive(Debug, Clone)]
pub(crate) struct Api {
client: reqwest::Client,
pub(crate) api_base: Url,
pub(crate) web_base: Url,
}
impl Api {
pub(crate) fn new(api_base: Url, web_base: Url, timeout: Duration) -> Result<Self> {
let client = reqwest::Client::builder()
.user_agent(concat!("vgi-forge-forgejo/", env!("CARGO_PKG_VERSION")))
.redirect(reqwest::redirect::Policy::none())
.timeout(timeout)
.build()
.map_err(|e| ForgeError::Config(format!("HTTP client: {e}")))?;
Ok(Api {
client,
api_base,
web_base,
})
}
pub(crate) fn url(&self, segments: &[&str]) -> Url {
join(&self.api_base, segments)
}
pub(crate) fn web_url(&self, segments: &[&str]) -> Url {
join(&self.web_base, segments)
}
pub(crate) async fn send(
&self,
method: Method,
url: Url,
auth: Auth<'_>,
body: Option<&Value>,
what: &str,
) -> Result<Response> {
let resp = self.raw(method, url, auth, body).await?;
check(resp, what).await
}
async fn raw(
&self,
method: Method,
url: Url,
auth: Auth<'_>,
body: Option<&Value>,
) -> Result<Response> {
let bodyless_write =
body.is_none() && matches!(method, Method::POST | Method::PUT | Method::PATCH);
let mut req = self
.client
.request(method, url)
.header(header::ACCEPT, "application/json");
req = req.header(header::AUTHORIZATION, auth_header(auth)?);
if let Some(body) = body {
req = req.json(body);
} else if bodyless_write {
req = req
.header(header::CONTENT_LENGTH, "0")
.body(Vec::<u8>::new());
}
req.send().await.map_err(|e| {
ForgeError::Unavailable(e.without_url().to_string())
})
}
pub(crate) async fn json<T: DeserializeOwned>(
&self,
method: Method,
url: Url,
auth: Auth<'_>,
body: Option<&Value>,
what: &str,
) -> Result<T> {
let resp = self.send(method, url, auth, body, what).await?;
decode(resp, what).await
}
pub(crate) async fn json_secret<T: DeserializeOwned>(
&self,
method: Method,
url: Url,
auth: Auth<'_>,
body: Option<&Value>,
what: &str,
) -> Result<T> {
let resp = self.send(method, url, auth, body, what).await?;
let bytes = Zeroizing::new(
resp.bytes()
.await
.map_err(|e| ForgeError::Unavailable(e.without_url().to_string()))?
.to_vec(),
);
serde_json::from_slice(&bytes).map_err(|e| ForgeError::Protocol(format!("{what}: {e}")))
}
pub(crate) async fn get_opt<T: DeserializeOwned>(
&self,
url: Url,
auth: Auth<'_>,
what: &str,
) -> Result<Option<T>> {
match self.json(Method::GET, url, auth, None, what).await {
Ok(v) => Ok(Some(v)),
Err(ForgeError::NotFound { .. }) => Ok(None),
Err(e) => Err(e),
}
}
pub(crate) async fn exists(&self, url: Url, auth: Auth<'_>, what: &str) -> Result<bool> {
match self.send(Method::GET, url, auth, None, what).await {
Ok(_) => Ok(true),
Err(ForgeError::NotFound { .. }) => Ok(false),
Err(e) => Err(e),
}
}
pub(crate) async fn get_all<T: DeserializeOwned>(
&self,
url: Url,
auth: Auth<'_>,
what: &str,
) -> Result<Vec<T>> {
let mut out: Vec<Value> = Vec::new();
let mut previous: Option<Vec<Value>> = None;
let mut done = false;
for page in 1..=MAX_PAGES {
let mut u = url.clone();
u.query_pairs_mut()
.append_pair("page", &page.to_string())
.append_pair("limit", &PAGE_LIMIT.to_string());
let resp = self.send(Method::GET, u, auth, None, what).await?;
let total = resp
.headers()
.get("x-total-count")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<usize>().ok());
let items: Vec<Value> = decode(resp, what).await?;
if items.is_empty() || previous.as_ref() == Some(&items) {
done = true;
break;
}
out.extend(items.iter().cloned());
if total.is_some_and(|t| out.len() >= t) {
done = true;
break;
}
previous = Some(items);
}
if !done {
return Err(ForgeError::Protocol(format!(
"{what}: more than {MAX_PAGES} pages"
)));
}
out.into_iter()
.map(|v| {
serde_json::from_value(v).map_err(|e| ForgeError::Protocol(format!("{what}: {e}")))
})
.collect()
}
pub(crate) async fn oauth_token<T: DeserializeOwned>(
&self,
url: Url,
form: &[(&str, &str)],
) -> Result<T> {
let body = Zeroizing::new(
url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(form)
.finish(),
);
let resp = self
.client
.post(url)
.header(header::ACCEPT, "application/json")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(body.as_bytes().to_vec())
.send()
.await
.map_err(|e| ForgeError::Unavailable(e.without_url().to_string()))?;
let resp = match resp.status() {
StatusCode::BAD_REQUEST | StatusCode::UNAUTHORIZED => resp,
_ => check(resp, "OAuth token exchange").await?,
};
let bytes = Zeroizing::new(
resp.bytes()
.await
.map_err(|e| ForgeError::Unavailable(e.without_url().to_string()))?
.to_vec(),
);
serde_json::from_slice(&bytes)
.map_err(|e| ForgeError::Protocol(format!("OAuth token exchange: {e}")))
}
}
fn auth_header(auth: Auth<'_>) -> Result<header::HeaderValue> {
let text = match auth {
Auth::Token(t) => Zeroizing::new(format!("token {}", t.expose())),
Auth::Bearer(t) => Zeroizing::new(format!("Bearer {}", t.expose())),
Auth::Basic { user, password } => {
let pair = Zeroizing::new(format!("{user}:{}", password.expose()));
Zeroizing::new(format!("Basic {}", STANDARD.encode(pair.as_bytes())))
}
};
let mut value = header::HeaderValue::try_from(text.as_str())
.map_err(|_| ForgeError::Config("credential is not a valid header value".into()))?;
value.set_sensitive(true);
Ok(value)
}
fn join(base: &Url, segments: &[&str]) -> Url {
let mut url = base.clone();
{
let mut path = url
.path_segments_mut()
.expect("instance URLs are http(s), which have paths");
path.pop_if_empty();
for s in segments {
path.push(s);
}
}
url
}
pub(crate) async fn decode<T: DeserializeOwned>(resp: Response, what: &str) -> Result<T> {
let bytes = resp
.bytes()
.await
.map_err(|e| ForgeError::Unavailable(e.without_url().to_string()))?;
serde_json::from_slice(&bytes).map_err(|e| ForgeError::Protocol(format!("{what}: {e}")))
}
async fn check(resp: Response, what: &str) -> Result<Response> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
if status.is_redirection() {
let location = resp
.headers()
.get(header::LOCATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("(no location)")
.to_string();
return Err(ForgeError::Moved {
what: what.to_string(),
location,
});
}
let retry_after = resp
.headers()
.get(header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok());
let message = error_message(resp).await;
Err(match status {
StatusCode::TOO_MANY_REQUESTS => ForgeError::RateLimited {
retry_after_secs: retry_after,
},
StatusCode::UNAUTHORIZED => ForgeError::Unauthorized(format!("{what}: {message}")),
StatusCode::FORBIDDEN => ForgeError::Forbidden(format!("{what}: {message}")),
StatusCode::NOT_FOUND => ForgeError::NotFound {
what: what.to_string(),
},
StatusCode::CONFLICT | StatusCode::UNPROCESSABLE_ENTITY | StatusCode::BAD_REQUEST => {
ForgeError::Rejected {
status: status.as_u16(),
message: format!("{what}: {message}"),
}
}
s if s.is_server_error() => ForgeError::Unavailable(format!("{what}: {s} {message}")),
s => ForgeError::Protocol(format!("{what}: unexpected {s} {message}")),
})
}
async fn error_message(resp: Response) -> String {
let Ok(body) = resp.json::<Value>().await else {
return "(no message)".into();
};
let mut msg = body
.get("message")
.and_then(Value::as_str)
.unwrap_or("(no message)")
.to_string();
if let Some(detail) = body
.get("errors")
.and_then(Value::as_array)
.and_then(|e| e.first())
{
let detail = detail
.get("message")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| detail.as_str().map(str::to_string))
.unwrap_or_else(|| detail.to_string());
msg = format!("{msg} ({detail})");
}
if msg.len() > 300 {
let mut end = 300;
while !msg.is_char_boundary(end) {
end -= 1;
}
msg.truncate(end);
msg.push('…');
}
msg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_segments_are_encoded_one_by_one() {
let base = Url::parse("https://git.example/sub/api/v1").unwrap();
let url = join(&base, &["repos", "acme", "a/b?c", "contents"]);
assert_eq!(
url.as_str(),
"https://git.example/sub/api/v1/repos/acme/a%2Fb%3Fc/contents"
);
let url = join(&base, &["repos", "acme", "..", "..", "..", "x"]);
assert!(url.path().starts_with("/sub/api/v1/repos"), "{url}");
}
#[test]
fn credentials_become_sensitive_headers() {
let t = Secret::new("abc");
let h = auth_header(Auth::Token(&t)).unwrap();
assert!(h.is_sensitive());
assert_eq!(h.to_str().unwrap(), "token abc");
let h = auth_header(Auth::Basic {
user: "bot",
password: &Secret::new("pw"),
})
.unwrap();
assert_eq!(h.to_str().unwrap(), "Basic Ym90OnB3");
let h = auth_header(Auth::Bearer(&t)).unwrap();
assert_eq!(h.to_str().unwrap(), "Bearer abc");
}
}