gitea_sdk/
lib.rs

1//! This crate is a simple Gitea API client. It's goal is to give you the ability to write
2//! exactly as much code as you need to interact with the specific parts of the Gitea API you need,
3//! but no more.
4//!
5//! # Usage
6//! The main way to interact with the Gitea API is through the `Client` struct. You can create a
7//! new [Client] by calling [Client::new] with the base URL of your Gitea instance and a personal
8//! token. The crate does currently not support basic HTML or OAuth2 authentication.
9//!
10//! Once you have obtained a [Client], you can interact with the Gitea API by calling the various
11//! methods the instance provides. For example, to create a new repository for the currently
12//! authenticated user, you can call:
13//! ```
14//! # use gitea_sdk::{Client, Auth};
15//! # async fn create_repo() {
16//! let client = Client::new("https://gitea.example.com", Auth::Token("your-token"));
17//! let repo = client
18//!     .user()
19//!     .create_repo("my-new-repo")
20//!     // Optional fields
21//!     .description("This is my new repo")
22//!     .private(true)
23//!     // Send the request
24//!     .send(&client)
25//!     .await
26//!     .unwrap();
27//! # }
28//! ```
29//!
30//! Similarly, to get a list of commits for a repository, you can call:
31//! ```
32//! # use gitea_sdk::{Client, Auth};
33//! # async fn get_commits() {
34//! let client = Client::new("https://gitea.example.com", Auth::Token("your-token"));
35//! let commits = client
36//!    .repos("owner", "repo-name")
37//!    .get_commits()
38//!    // Optional fields
39//!    .page(2)
40//!    .send(&client)
41//!    .await
42//!    .unwrap();
43//! # }
44//! ```
45//!
46//! If you want to create a new access token for a user, you can call:
47//! ```
48//! # use gitea_sdk::{Client, CreateAccessTokenOption, Auth};
49//! # async fn create_access_token() {
50//! let basic = Auth::Basic("username", "password");
51//! let client = Client::new("https://gitea.example.com", basic);
52//! let token = client
53//!     .user()
54//!     .create_access_token("username", "my-new-token", vec!["write:repo"])
55//!     .send(&client)
56//!     .await
57//!     .unwrap();
58//! println!("Token {} created: {}", token.name, token.sha1);
59//! // You can now create a new client with the token and use it to interact with the API.
60//! let new_client = Client::new("https://gitea.example.com", Auth::Token(token.sha1));
61//! # }
62//!
63//!
64use base64::engine::{GeneralPurpose, GeneralPurposeConfig};
65use base64::{alphabet, Engine};
66use error::{Result, TeatimeError};
67use std::fmt::Display;
68
69use reqwest::header::{self, HeaderMap, HeaderValue};
70use reqwest::{Method, Response};
71use serde::{de::DeserializeOwned, Deserialize, Serialize};
72
73pub mod error;
74
75pub mod api;
76pub mod model;
77
78#[derive(Default, Debug, Clone, Serialize, Deserialize)]
79pub struct CreateAccessTokenOption {
80    /// Access token name.
81    pub name: String,
82    /// Optional scopes for the access token.
83    pub scopes: Option<Vec<String>>,
84}
85
86/// Represents the authentication method to use with the Gitea API.
87pub enum Auth<D: ToString> {
88    Token(D),
89    Basic(D, D),
90    None,
91}
92
93/// Represents a Gitea client.
94///
95/// This struct is the main way to interact with the Gitea API.
96/// It provides methods for creating repositories, getting repositories, deleting repositories,
97/// and listing a repo's commits.
98pub struct Client {
99    cli: reqwest::Client,
100    base_url: String,
101}
102
103impl Client {
104    /// Creates a new Gitea client with the given base URL and personal token.
105    /// NOTE: The base URL MUST not include the `/api/v1` path and should not contain any trailing
106    /// slashes. For example, `https://gitea.example.com` is a valid base URL, but
107    /// `https://gitea.example.com/` or `https://gitea.example.com/api/v1` are not.
108    pub fn new(base_url: impl ToString, auth: Auth<impl ToString>) -> Self {
109        let mut headers = HeaderMap::new();
110        match auth {
111            Auth::Token(token) => {
112                let token = HeaderValue::from_str(&format!("token {}", token.to_string()))
113                    .expect("token error");
114                headers.insert(header::AUTHORIZATION, token);
115            }
116            Auth::Basic(user, pass) => {
117                let engine = GeneralPurpose::new(&alphabet::STANDARD, GeneralPurposeConfig::new());
118                let base = engine.encode(format!("{}:{}", user.to_string(), pass.to_string()));
119                let basic =
120                    HeaderValue::from_str(&format!("Basic {base}")).expect("basic auth error");
121                headers.insert(header::AUTHORIZATION, basic);
122            }
123            Auth::None => {}
124        };
125        headers.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
126
127        let cli = reqwest::ClientBuilder::new()
128            .default_headers(headers)
129            .user_agent(format!(
130                "{}/{}",
131                env!("CARGO_PKG_NAME"),
132                env!("CARGO_PKG_VERSION")
133            ))
134            .build()
135            .expect("client build error");
136
137        Self {
138            cli,
139            base_url: base_url.to_string(),
140        }
141    }
142
143    pub fn repos(&self, owner: impl ToString, repo: impl ToString) -> api::repos::Repos {
144        api::repos::Repos {
145            owner: owner.to_string(),
146            repo: repo.to_string(),
147        }
148    }
149
150    /// Migrates a repository from another service to Gitea.
151    ///
152    /// This will create a new repository in Gitea with the same name as the repository in the
153    /// source service and copy all the data from the source repository to the new repository.
154    /// The source repository will not be modified.
155    ///
156    /// Gitea supports pull-mirrors, which will keep the new repository in sync with the source
157    /// repository. This is useful if you want to keep the new repository up-to-date with the
158    /// source repository.
159    ///
160    /// # Example
161    ///
162    /// ```
163    /// # use gitea_sdk::{Client, Auth};
164    /// # async fn migrate_repo() {
165    /// let client = Client::new("https://gitea.example.com", Auth::Token("your-token"));
166    /// let repo = client
167    ///     .migrate_repo("https://example.git.com/owner/repo", "repo")
168    ///     .mirror(true)
169    ///     .mirror_interval("1h")
170    ///     .send(&client)
171    ///     .await
172    ///     .unwrap();
173    /// # }
174    /// ```
175    /// This will create a new repository in Gitea with the name `repo` and copy all the data from
176    /// the repository at `https://example.git.com/owner/repo` to the new repository. The new
177    /// repository will be kept in sync with the source repository every hour.
178    pub fn migrate_repo(
179        &self,
180        clone_addr: impl ToString,
181        repo_name: impl ToString,
182    ) -> api::migrate::MigrateRepoBuilder {
183        api::migrate::MigrateRepoBuilder::new(clone_addr, repo_name)
184    }
185
186    pub fn issues(&self, owner: impl ToString, repo: impl ToString) -> api::issues::Issues {
187        api::issues::Issues {
188            owner: owner.to_string(),
189            repo: repo.to_string(),
190        }
191    }
192
193    pub fn pulls(&self, owner: impl ToString, repo: impl ToString) -> api::pulls::Pulls {
194        api::pulls::Pulls {
195            owner: owner.to_string(),
196            repo: repo.to_string(),
197        }
198    }
199
200    pub fn search(&self) -> api::search::Search {
201        api::search::Search
202    }
203
204    pub fn user(&self) -> api::user::User {
205        api::user::User
206    }
207
208    pub fn users(&self, username: impl ToString) -> api::users::Users {
209        api::users::Users {
210            username: username.to_string(),
211        }
212    }
213
214    pub fn orgs(&self, name: impl ToString) -> api::orgs::Orgs {
215        api::orgs::Orgs {
216            name: name.to_string(),
217        }
218    }
219
220    /// Creates a new DELETE-request builder with the given path.
221    /// You may use this method to talk to the Gitea API directly if you need to.
222    /// `path` will be prefixed with `{base_url}/api/v1/` before the request is sent.
223    pub fn delete(&self, path: impl Display) -> reqwest::RequestBuilder {
224        self.request_base(Method::DELETE, path)
225    }
226    /// Creates a new PATCH-request builder with the given path.
227    /// You may use this method to talk to the Gitea API directly if you need to.
228    /// `path` will be prefixed with `{base_url}/api/v1/` before the request is sent.
229    pub fn patch(&self, path: impl Display) -> reqwest::RequestBuilder {
230        self.request_base(Method::PATCH, path)
231    }
232    /// Creates a new POST-request builder with the given path.
233    /// You may use this method to talk to the Gitea API directly if you need to.
234    /// `path` will be prefixed with `{base_url}/api/v1/` before the request is sent.
235    pub fn post(&self, path: impl Display) -> reqwest::RequestBuilder {
236        self.request_base(Method::POST, path)
237    }
238    /// Creates a new POST-request builder with the given path.
239    /// You may use this method to talk to the Gitea API directly if you need to.
240    /// `path` will be prefixed with `{base_url}/api/v1/` before the request is sent.
241    pub fn get(&self, path: impl Display) -> reqwest::RequestBuilder {
242        self.request_base(Method::GET, path)
243    }
244    /// Creates a new PUT-request builder with the given path.
245    /// You may use this method to talk to the Gitea API directly if you need to.
246    /// `path` will be prefixed with `{base_url}/api/v1/` before the request is sent.
247    pub fn put(&self, path: impl Display) -> reqwest::RequestBuilder {
248        self.request_base(Method::PUT, path)
249    }
250
251    /// Creates a new request builder with the given method and path.
252    /// You may use this method to talk to the Gitea API directly if you need to.
253    /// `path` will be prefixed with `{base_url}/api/v1/` before the request is sent.
254    pub fn request_base(&self, method: Method, path: impl Display) -> reqwest::RequestBuilder {
255        self.cli
256            .request(method, format!("{}/api/v1/{}", self.base_url, path))
257    }
258    /// Sends a request and checks the response for errors.
259    /// You may use this method to talk to the Gitea API directly if you need to.
260    /// This method will return a [TeatimeError] if the request fails.
261    /// NOTE: This method is not recommended for general use. Use the more specific methods
262    /// provided by the [Client] struct if they exist.
263    /// You are responsible for providing the correct Model for the response.
264    pub async fn make_request(&self, req: reqwest::Request) -> Result<Response> {
265        let res = self.cli.execute(req).await?;
266        let status = res.status();
267        if status.is_client_error() || status.is_server_error() {
268            return Err(TeatimeError {
269                message: res.text().await.unwrap_or_default(),
270                kind: error::TeatimeErrorKind::HttpError,
271                status_code: status,
272            });
273        }
274        Ok(res)
275    }
276    /// Parses a json response into a given model.
277    /// You may use this method to talk to the Gitea API directly if you need to.
278    /// This method will return a [TeatimeError] if the response cannot be deserialized.
279    /// NOTE: This method is not recommended for general use. Use the more specific methods
280    /// provided by the [Client] struct if they exist.
281    /// You are responsible for providing the correct Model for the response.
282    pub async fn parse_response<T: DeserializeOwned>(&self, res: reqwest::Response) -> Result<T> {
283        let status_code = res.status();
284        let text = res.text().await?;
285        serde_json::from_str(&text).map_err(|e| TeatimeError {
286            message: format!("Error parsing response: {}", e),
287            kind: error::TeatimeErrorKind::SerializationError,
288            status_code,
289        })
290    }
291}