mod app;
mod commit;
mod installation;
mod issue;
mod pagination;
mod project;
mod pull_request;
mod rate_limit;
mod release;
mod repository;
mod retry;
mod workflow;
use std::sync::Arc;
use std::time::Duration;
use reqwest;
use crate::auth::{AuthenticationProvider, Installation, InstallationId};
use crate::error::ApiError;
pub(in crate::client) async fn map_http_error(
status: reqwest::StatusCode,
response: reqwest::Response,
) -> ApiError {
match status.as_u16() {
401 => ApiError::AuthenticationFailed,
403 => ApiError::AuthorizationFailed,
404 => ApiError::NotFound,
422 => {
let message = response
.text()
.await
.unwrap_or_else(|_| "Validation failed".to_string());
ApiError::InvalidRequest { message }
}
_ => {
let message = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
ApiError::HttpError {
status: status.as_u16(),
message,
}
}
}
}
pub use app::App;
pub use commit::{
CommitDetails, CommitReference, Comparison, FileChange, FullCommit, GitSignature, Verification,
};
pub use installation::InstallationClient;
pub use issue::{
Comment, CreateCommentRequest, CreateIssueRequest, CreateLabelRequest, CreateMilestoneRequest,
Issue, IssueActivityEvent, IssueRename, IssueUser, IssuesClient, Label, LabelsClient,
LockReason, Milestone, MilestoneSortField, MilestoneState, MilestoneSummary, MilestonesClient,
Reaction, ReactionContent, SortDirection, TimelineEvent, UpdateCommentRequest,
UpdateIssueRequest, UpdateLabelRequest, UpdateMilestoneRequest,
};
pub use pagination::{extract_page_number, parse_link_header, PagedResponse, Pagination};
pub use project::{
AddProjectV2ItemRequest, ProjectOwner, ProjectV2, ProjectV2Item, ProjectsClient,
};
pub use pull_request::{
CreatePullRequestCommentRequest, CreatePullRequestRequest, CreateReviewRequest,
DismissReviewRequest, MergePullRequestRequest, MergeResult, PullRequest, PullRequestBranch,
PullRequestComment, PullRequestRepo, PullRequestsClient, Review,
UpdatePullRequestCommentRequest, UpdatePullRequestRequest, UpdateReviewRequest,
};
pub use rate_limit::{parse_rate_limit_from_headers, RateLimit, RateLimitContext, RateLimiter};
pub use release::{
CreateReleaseRequest, Release, ReleaseAsset, ReleasesClient, UpdateReleaseRequest,
};
pub use repository::{
Branch, Commit, GitRef, OwnerType, RepositoriesClient, Repository, RepositoryOwner, Tag,
};
pub use retry::{
calculate_rate_limit_delay, detect_secondary_rate_limit, parse_retry_after, RateLimitInfo,
RetryPolicy,
};
pub use workflow::{
TriggerWorkflowRequest, Workflow, WorkflowRun, WorkflowRunConclusion, WorkflowRunStatus,
WorkflowState, WorkflowsClient,
};
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub user_agent: String,
pub timeout: Duration,
pub max_retries: u32,
pub initial_retry_delay: Duration,
pub max_retry_delay: Duration,
pub rate_limit_margin: f64,
pub github_api_url: String,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
user_agent: "github-bot-sdk/0.1.0".to_string(),
timeout: Duration::from_secs(30),
max_retries: 3,
initial_retry_delay: Duration::from_millis(100),
max_retry_delay: Duration::from_secs(60),
rate_limit_margin: 0.1, github_api_url: "https://api.github.com".to_string(),
}
}
}
impl ClientConfig {
pub fn builder() -> ClientConfigBuilder {
ClientConfigBuilder::new()
}
pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn with_max_retries(mut self, max_retries: u32) -> Self {
self.max_retries = max_retries;
self
}
pub fn with_rate_limit_margin(mut self, margin: f64) -> Self {
self.rate_limit_margin = margin.clamp(0.0, 1.0);
self
}
pub fn with_github_api_url(mut self, url: impl Into<String>) -> Self {
self.github_api_url = url.into();
self
}
}
#[derive(Debug)]
pub struct ClientConfigBuilder {
config: ClientConfig,
}
impl ClientConfigBuilder {
pub fn new() -> Self {
Self {
config: ClientConfig::default(),
}
}
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.config.user_agent = user_agent.into();
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.config.timeout = timeout;
self
}
pub fn max_retries(mut self, max_retries: u32) -> Self {
self.config.max_retries = max_retries;
self
}
pub fn rate_limit_margin(mut self, margin: f64) -> Self {
self.config.rate_limit_margin = margin.clamp(0.0, 1.0);
self
}
pub fn github_api_url(mut self, url: impl Into<String>) -> Self {
self.config.github_api_url = url.into();
self
}
pub fn build(self) -> ClientConfig {
self.config
}
}
impl Default for ClientConfigBuilder {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone)]
pub struct GitHubClient {
auth: Arc<dyn AuthenticationProvider>,
http_client: reqwest::Client,
config: ClientConfig,
}
impl GitHubClient {
pub fn builder(auth: impl AuthenticationProvider + 'static) -> GitHubClientBuilder {
GitHubClientBuilder::new(auth)
}
pub fn config(&self) -> &ClientConfig {
&self.config
}
pub fn auth_provider(&self) -> &dyn AuthenticationProvider {
self.auth.as_ref()
}
pub(crate) fn http_client(&self) -> &reqwest::Client {
&self.http_client
}
pub async fn get_app(&self) -> Result<App, ApiError> {
let jwt = self
.auth
.app_token()
.await
.map_err(|e| ApiError::TokenGenerationFailed {
message: format!("Failed to generate JWT: {}", e),
})?;
let url = format!("{}/app", self.config.github_api_url);
let response = self
.http_client
.get(&url)
.header("Authorization", format!("Bearer {}", jwt.token()))
.header("Accept", "application/vnd.github+json")
.send()
.await
.map_err(|e| ApiError::Configuration {
message: format!("HTTP request failed: {}", e),
})?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unable to read error body".to_string());
return Err(ApiError::Configuration {
message: format!("API request failed with status {}: {}", status, error_text),
});
}
let app = response
.json::<App>()
.await
.map_err(|e| ApiError::Configuration {
message: format!("Failed to parse App response: {}", e),
})?;
Ok(app)
}
pub async fn list_installations(&self) -> Result<Vec<Installation>, ApiError> {
let jwt = self
.auth
.app_token()
.await
.map_err(|e| ApiError::TokenGenerationFailed {
message: format!("Failed to generate JWT: {}", e),
})?;
let url = format!("{}/app/installations", self.config.github_api_url);
let response = self
.http_client
.get(&url)
.header("Authorization", format!("Bearer {}", jwt.token()))
.header("Accept", "application/vnd.github+json")
.send()
.await
.map_err(|e| ApiError::Configuration {
message: format!("HTTP request failed: {}", e),
})?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unable to read error body".to_string());
return Err(ApiError::Configuration {
message: format!("API request failed with status {}: {}", status, error_text),
});
}
let installations =
response
.json::<Vec<Installation>>()
.await
.map_err(|e| ApiError::Configuration {
message: format!("Failed to parse installations response: {}", e),
})?;
Ok(installations)
}
pub async fn get_installation(
&self,
installation_id: InstallationId,
) -> Result<Installation, ApiError> {
let jwt = self
.auth
.app_token()
.await
.map_err(|e| ApiError::TokenGenerationFailed {
message: format!("Failed to generate JWT: {}", e),
})?;
let url = format!(
"{}/app/installations/{}",
self.config.github_api_url,
installation_id.as_u64()
);
let response = self
.http_client
.get(&url)
.header("Authorization", format!("Bearer {}", jwt.token()))
.header("Accept", "application/vnd.github+json")
.send()
.await
.map_err(|e| ApiError::Configuration {
message: format!("HTTP request failed: {}", e),
})?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unable to read error body".to_string());
return Err(ApiError::Configuration {
message: format!("API request failed with status {}: {}", status, error_text),
});
}
let installation =
response
.json::<Installation>()
.await
.map_err(|e| ApiError::Configuration {
message: format!("Failed to parse installation response: {}", e),
})?;
Ok(installation)
}
pub async fn get_as_app(&self, path: &str) -> Result<reqwest::Response, ApiError> {
let jwt = self
.auth
.app_token()
.await
.map_err(|e| ApiError::TokenGenerationFailed {
message: format!("Failed to generate JWT: {}", e),
})?;
let normalized_path = path.strip_prefix('/').unwrap_or(path);
let url = format!("{}/{}", self.config.github_api_url, normalized_path);
let response = self
.http_client
.get(&url)
.header("Authorization", format!("Bearer {}", jwt.token()))
.header("Accept", "application/vnd.github+json")
.send()
.await
.map_err(|e| ApiError::Configuration {
message: format!("HTTP request failed: {}", e),
})?;
Ok(response)
}
pub async fn post_as_app(
&self,
path: &str,
body: &impl serde::Serialize,
) -> Result<reqwest::Response, ApiError> {
let jwt = self
.auth
.app_token()
.await
.map_err(|e| ApiError::TokenGenerationFailed {
message: format!("Failed to generate JWT: {}", e),
})?;
let normalized_path = path.strip_prefix('/').unwrap_or(path);
let url = format!("{}/{}", self.config.github_api_url, normalized_path);
let response = self
.http_client
.post(&url)
.header("Authorization", format!("Bearer {}", jwt.token()))
.header("Accept", "application/vnd.github+json")
.json(body)
.send()
.await
.map_err(|e| ApiError::Configuration {
message: format!("HTTP request failed: {}", e),
})?;
Ok(response)
}
}
impl std::fmt::Debug for GitHubClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GitHubClient")
.field("config", &self.config)
.field("auth", &"<AuthenticationProvider>")
.finish()
}
}
pub struct GitHubClientBuilder {
auth: Arc<dyn AuthenticationProvider>,
config: Option<ClientConfig>,
}
impl GitHubClientBuilder {
fn new(auth: impl AuthenticationProvider + 'static) -> Self {
Self {
auth: Arc::new(auth),
config: None,
}
}
pub fn config(mut self, config: ClientConfig) -> Self {
self.config = Some(config);
self
}
pub fn build(self) -> Result<GitHubClient, ApiError> {
let config = self.config.unwrap_or_default();
let http_client = reqwest::Client::builder()
.timeout(config.timeout)
.user_agent(&config.user_agent)
.build()
.map_err(|e| ApiError::Configuration {
message: format!("Failed to create HTTP client: {}", e),
})?;
Ok(GitHubClient {
auth: self.auth,
http_client,
config,
})
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;