initial commit
This commit is contained in:
207
crates/gitea/src/lib.rs
Normal file
207
crates/gitea/src/lib.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
use std::{error, fmt};
|
||||
|
||||
use reqwest::{
|
||||
Client as HttpClient,
|
||||
header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue},
|
||||
};
|
||||
pub use reqwest::{Method, RequestBuilder, Response, StatusCode, Url};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub use gitea_openapi::{apis, models};
|
||||
pub use models::{Repository, ServerVersion, User};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Configuration(String),
|
||||
Transport(reqwest::Error),
|
||||
Generated(String),
|
||||
Api { status: StatusCode, message: String },
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Configuration(message) => formatter.write_str(message),
|
||||
Self::Transport(error) => error.fmt(formatter),
|
||||
Self::Generated(message) => formatter.write_str(message),
|
||||
Self::Api { status, message } => {
|
||||
write!(formatter, "Gitea returned {status}: {message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Transport(error) => Some(error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for Error {
|
||||
fn from(error: reqwest::Error) -> Self {
|
||||
Self::Transport(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Client {
|
||||
api_url: Url,
|
||||
http: HttpClient,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new(instance_url: &str, token: Option<&str>) -> Result<Self> {
|
||||
let mut instance_url = Url::parse(instance_url)
|
||||
.map_err(|error| Error::Configuration(format!("invalid Gitea URL: {error}")))?;
|
||||
|
||||
if !matches!(instance_url.scheme(), "http" | "https") {
|
||||
return Err(Error::Configuration(
|
||||
"Gitea URL must use http or https".into(),
|
||||
));
|
||||
}
|
||||
if !instance_url.username().is_empty() || instance_url.password().is_some() {
|
||||
return Err(Error::Configuration(
|
||||
"Gitea URL must not contain credentials".into(),
|
||||
));
|
||||
}
|
||||
if !instance_url.path().ends_with('/') {
|
||||
instance_url.set_path(&format!("{}/", instance_url.path()));
|
||||
}
|
||||
|
||||
let api_url = instance_url
|
||||
.join("api/v1/")
|
||||
.map_err(|error| Error::Configuration(format!("invalid Gitea URL: {error}")))?;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||
if let Some(token) = token {
|
||||
let value = HeaderValue::from_str(&format!("token {token}"))
|
||||
.map_err(|_| Error::Configuration("invalid Gitea token".into()))?;
|
||||
headers.insert(AUTHORIZATION, value);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
api_url,
|
||||
http: HttpClient::builder().default_headers(headers).build()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn api_url(&self) -> &Url {
|
||||
&self.api_url
|
||||
}
|
||||
|
||||
/// Returns an authenticated configuration for every generated typed API.
|
||||
pub fn configuration(&self) -> apis::configuration::Configuration {
|
||||
apis::configuration::Configuration {
|
||||
base_path: self.api_url.as_str().trim_end_matches('/').into(),
|
||||
client: self.http.clone(),
|
||||
user_agent: Some(format!("gotcha_gitea/{}", env!("CARGO_PKG_VERSION"))),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an authenticated request to an API-relative endpoint.
|
||||
///
|
||||
/// The returned reqwest builder supports JSON, forms, multipart uploads,
|
||||
/// query parameters, and byte streams without duplicating those APIs here.
|
||||
pub fn request(&self, method: Method, endpoint: &str) -> Result<RequestBuilder> {
|
||||
if endpoint.starts_with('/')
|
||||
|| Url::parse(endpoint).is_ok()
|
||||
|| endpoint.split('/').any(|segment| segment == "..")
|
||||
{
|
||||
return Err(Error::Configuration(
|
||||
"endpoint must be a relative path below /api/v1".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let url = self
|
||||
.api_url
|
||||
.join(endpoint)
|
||||
.map_err(|error| Error::Configuration(format!("invalid endpoint: {error}")))?;
|
||||
Ok(self.http.request(method, url))
|
||||
}
|
||||
|
||||
pub async fn execute(&self, request: RequestBuilder) -> Result<Response> {
|
||||
let response = request.send().await?;
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let body = response.text().await?;
|
||||
let message = serde_json::from_str::<ApiError>(&body)
|
||||
.ok()
|
||||
.map(|error| error.message)
|
||||
.filter(|message| !message.is_empty())
|
||||
.unwrap_or(body);
|
||||
Err(Error::Api { status, message })
|
||||
}
|
||||
|
||||
pub async fn version(&self) -> Result<models::ServerVersion> {
|
||||
apis::miscellaneous_api::get_version(&self.configuration())
|
||||
.await
|
||||
.map_err(|error| Error::Generated(error.to_string()))
|
||||
}
|
||||
|
||||
pub async fn current_user(&self) -> Result<models::User> {
|
||||
apis::user_api::user_get_current(&self.configuration())
|
||||
.await
|
||||
.map_err(|error| Error::Generated(error.to_string()))
|
||||
}
|
||||
|
||||
pub async fn current_user_repositories(&self) -> Result<Vec<models::Repository>> {
|
||||
apis::user_api::user_current_list_repos(&self.configuration(), None, None)
|
||||
.await
|
||||
.map_err(|error| Error::Generated(error.to_string()))
|
||||
}
|
||||
|
||||
pub async fn repository(&self, owner: &str, repository: &str) -> Result<models::Repository> {
|
||||
if owner.is_empty() || repository.is_empty() {
|
||||
return Err(Error::Configuration(
|
||||
"repository owner and name must not be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
apis::repository_api::repo_get(&self.configuration(), owner, repository)
|
||||
.await
|
||||
.map_err(|error| Error::Generated(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiError {
|
||||
#[serde(default)]
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_instance_subpaths_and_rejects_endpoint_escapes() {
|
||||
let client = Client::new("https://example.com/gitea", Some("secret")).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
client.api_url().as_str(),
|
||||
"https://example.com/gitea/api/v1/"
|
||||
);
|
||||
assert!(client.request(Method::GET, "repos/octo/demo").is_ok());
|
||||
assert!(client.request(Method::GET, "../admin").is_err());
|
||||
assert!(
|
||||
client
|
||||
.request(Method::GET, "https://example.net/user")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
client.configuration().base_path,
|
||||
"https://example.com/gitea/api/v1"
|
||||
);
|
||||
let _typed_query = apis::issue_api::issue_list_issues;
|
||||
let _typed_model = models::Issue::default();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user