initial commit

This commit is contained in:
Georg Bauer
2026-07-30 11:41:19 +02:00
commit f21292978c
12 changed files with 3715 additions and 0 deletions

13
crates/gitea/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "gotcha_gitea"
version = "0.1.0"
description = "A small, reusable Rust client for the Gitea API"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
gitea-openapi = { package = "gitea-client", version = "=1.25.2", default-features = false, features = ["rustls"] }
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true

207
crates/gitea/src/lib.rs Normal file
View 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();
}
}