github_bot_sdk/client/mod.rs
1//! GitHub API client for authenticated operations.
2//!
3//! This module provides the primary interface for interacting with GitHub's REST API as a GitHub App.
4//! It handles authentication, rate limiting, retries, pagination, and provides type-safe operations
5//! for repositories, issues, pull requests, projects, and more.
6//!
7//! # Overview
8//!
9//! The client operates at two levels:
10//!
11//! - **App-level** ([`GitHubClient`]) - Operations as the GitHub App itself (using JWT)
12//! - **Installation-level** ([`InstallationClient`]) - Operations within a specific installation (using installation tokens)
13//!
14//! Most operations happen at the installation level, where you interact with repositories,
15//! issues, pull requests, etc. on behalf of the installation.
16//!
17//! # Features
18//!
19//! - **Automatic Authentication** - Tokens are injected and refreshed automatically
20//! - **Rate Limit Handling** - Detects rate limits and backs off appropriately
21//! - **Retry Logic** - Exponential backoff for transient failures (network errors, 5xx responses)
22//! - **Pagination Support** - Helper methods for paginated API responses
23//! - **Type Safety** - Strongly-typed request and response models
24//! - **Configurable** - Timeouts, retry behavior, rate limit margins
25//!
26//! # Quick Start
27//!
28//! ```no_run
29//! use github_bot_sdk::{
30//! auth::{AuthenticationProvider, InstallationId},
31//! client::{GitHubClient, ClientConfig},
32//! error::ApiError,
33//! };
34//! use std::time::Duration;
35//!
36//! # async fn example(auth: impl AuthenticationProvider + 'static) -> Result<(), ApiError> {
37//! // Create client with custom configuration
38//! let client = GitHubClient::builder(auth)
39//! .config(ClientConfig::default()
40//! .with_user_agent("my-bot/1.0")
41//! .with_timeout(Duration::from_secs(30))
42//! .with_max_retries(5))
43//! .build()?;
44//!
45//! // Get app information (app-level operation)
46//! let app = client.get_app().await?;
47//! println!("Running as: {}", app.name);
48//!
49//! // Get installation information
50//! let installation_id = InstallationId::new(12345);
51//! let installation = client.get_installation(installation_id).await?;
52//! println!("Installation: {}", installation.id.as_u64());
53//! # Ok(())
54//! # }
55//! ```
56//!
57//! # Configuration
58//!
59//! Use [`ClientConfig`] to customize client behavior:
60//!
61//! ```
62//! use github_bot_sdk::client::ClientConfig;
63//! use std::time::Duration;
64//!
65//! let config = ClientConfig::default()
66//! .with_user_agent("my-github-bot/1.0") // Required by GitHub
67//! .with_timeout(Duration::from_secs(60)) // Request timeout
68//! .with_max_retries(5) // Max retry attempts
69//! .with_rate_limit_margin(0.1) // Keep 10% rate limit buffer
70//! .with_github_api_url("https://api.github.com"); // Override for GHE
71//!
72//! // Or use the builder pattern
73//! let config = ClientConfig::builder()
74//! .user_agent("my-bot/2.0")
75//! .timeout(Duration::from_secs(45))
76//! .max_retries(3)
77//! .build();
78//! ```
79//!
80//! # Operations by Category
81//!
82//! ## Repository Operations
83//!
84//! ```no_run
85//! # use github_bot_sdk::client::GitHubClient;
86//! # use github_bot_sdk::auth::InstallationId;
87//! # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
88//! let installation_id = InstallationId::new(12345);
89//! let _installation = client.get_installation(installation_id).await?;
90//!
91//! // Repository operations are available through client methods
92//! // See InstallationClient and related modules for detailed API
93//! # Ok(())
94//! # }
95//! ```
96//!
97//! ## Issue Operations
98//!
99//! ```no_run
100//! # use github_bot_sdk::client::GitHubClient;
101//! # use github_bot_sdk::auth::InstallationId;
102//! # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
103//! let installation_id = InstallationId::new(12345);
104//! let _installation = client.get_installation(installation_id).await?;
105//!
106//! // Issue operations through client methods
107//! // Create, comment, label, and manage issues
108//! # Ok(())
109//! # }
110//! ```
111//!
112//! ## Pull Request Operations
113//!
114//! ```no_run
115//! # use github_bot_sdk::client::GitHubClient;
116//! # use github_bot_sdk::auth::InstallationId;
117//! # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
118//! let installation_id = InstallationId::new(12345);
119//! let _installation = client.get_installation(installation_id).await?;
120//!
121//! // Pull request operations through client methods
122//! // Create, review, merge, and manage PRs
123//! # Ok(())
124//! # }
125//! ```
126//!
127//! ## Project Operations (GitHub Projects V2)
128//!
129//! ```no_run
130//! # use github_bot_sdk::client::GitHubClient;
131//! # use github_bot_sdk::auth::InstallationId;
132//! # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
133//! let installation_id = InstallationId::new(12345);
134//! let _installation = client.get_installation(installation_id).await?;
135//!
136//! // Project operations through client methods
137//! # Ok(())
138//! # }
139//! ```
140//!
141//! # Rate Limiting
142//!
143//! The client automatically handles GitHub's rate limits:
144//!
145//! - **Detection** - Monitors `X-RateLimit-*` headers in responses
146//! - **Proactive Backoff** - Slows down when approaching limit (configurable margin)
147//! - **Reactive Handling** - Respects `Retry-After` headers on 429 responses
148//! - **Secondary Rate Limits** - Handles abuse detection (403 with retry-after)
149//!
150//! ```no_run
151//! # use github_bot_sdk::client::{GitHubClient, ClientConfig};
152//! # use github_bot_sdk::auth::AuthenticationProvider;
153//! # async fn example(auth: impl AuthenticationProvider + 'static) -> Result<(), Box<dyn std::error::Error>> {
154//! // Configure rate limit behavior
155//! let config = ClientConfig::default()
156//! .with_rate_limit_margin(0.15); // Start backing off at 85% of limit
157//!
158//! let client = GitHubClient::builder(auth)
159//! .config(config)
160//! .build()?;
161//!
162//! // Client will automatically handle rate limits
163//! // No manual rate limit checking needed
164//! # Ok(())
165//! # }
166//! ```
167//!
168//! # Retry Behavior
169//!
170//! The client automatically retries transient failures with exponential backoff:
171//!
172//! - **Retryable Errors**:
173//! - Network errors (timeouts, connection failures)
174//! - 5xx server errors (GitHub is having issues)
175//! - 429 rate limit exceeded (respects Retry-After)
176//! - 403 with Retry-After (secondary rate limit)
177//!
178//! - **Non-Retryable Errors**:
179//! - 4xx client errors (except 429 and some 403s)
180//! - Authentication failures
181//! - Validation errors
182//!
183//! ```no_run
184//! use github_bot_sdk::client::ClientConfig;
185//! use std::time::Duration;
186//!
187//! // Configure retry behavior
188//! let config = ClientConfig::default()
189//! .with_max_retries(5) // Max attempts
190//! .with_timeout(Duration::from_secs(30)); // Request timeout
191//!
192//! // Default exponential backoff is applied automatically
193//! ```
194//!
195//! # Pagination
196//!
197//! GitHub's API uses Link headers for pagination. See specific operation documentation for examples.
198//!
199//! ```no_run
200//! # use github_bot_sdk::client::GitHubClient;
201//! # use github_bot_sdk::auth::InstallationId;
202//! # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
203//! let installation_id = InstallationId::new(12345);
204//!
205//! // Get installation
206//! let installation = client.get_installation(installation_id).await?;
207//! println!("Installation: {}", installation.id.as_u64());
208//! # Ok(())
209//! # }
210//! ```
211//!
212//! # Error Handling
213//!
214//! All operations return [`Result<T, ApiError>`]:
215//!
216//! ```no_run
217//! # use github_bot_sdk::{client::GitHubClient, error::ApiError, auth::InstallationId};
218//! # async fn example(client: &GitHubClient) -> Result<(), ApiError> {
219//! let installation_id = InstallationId::new(12345);
220//!
221//! // Error handling example
222//! match client.get_installation(installation_id).await {
223//! Ok(installation) => println!("Found installation: {}", installation.id.as_u64()),
224//! Err(ApiError::NotFound { .. }) => {
225//! println!("Installation doesn't exist or no access");
226//! }
227//! Err(ApiError::RateLimitExceeded { reset_at, .. }) => {
228//! println!("Rate limited, resets at: {:?}", reset_at);
229//! }
230//! Err(ApiError::AuthorizationFailed) => {
231//! println!("Insufficient permissions");
232//! }
233//! Err(e) => return Err(e),
234//! }
235//! # Ok(())
236//! # }
237//! ```
238//!
239//! # GitHub Enterprise Support
240//!
241//! To use with GitHub Enterprise Server, configure the API base URL:
242//!
243//! ```
244//! use github_bot_sdk::client::ClientConfig;
245//!
246//! let config = ClientConfig::default()
247//! .with_github_api_url("https://github.example.com/api/v3");
248//! ```
249//!
250//! # See Also
251//!
252//! - [`crate::auth`] - Authentication types and providers
253//! - [`crate::webhook`] - Webhook validation for incoming events
254//! - [GitHub REST API Documentation](https://docs.github.com/en/rest)
255
256mod app;
257mod commit;
258mod installation;
259mod issue;
260mod pagination;
261mod project;
262mod pull_request;
263mod rate_limit;
264mod release;
265mod repository;
266mod retry;
267mod workflow;
268
269use std::sync::Arc;
270use std::time::Duration;
271
272use reqwest;
273
274use crate::auth::{AuthenticationProvider, Installation, InstallationId};
275use crate::error::ApiError;
276
277// ============================================================================
278// Shared HTTP error mapping
279// ============================================================================
280
281/// Map an unsuccessful HTTP response to the canonical `ApiError` variant.
282///
283/// Called by sub-client modules (`issue`, `pull_request`) and
284/// `InstallationClient::fetch_all_pages`. The response body is consumed only
285/// for status codes that carry a useful message (422 and any unrecognised
286/// error), avoiding unnecessary I/O on 401 / 403 / 404 responses.
287pub(in crate::client) async fn map_http_error(
288 status: reqwest::StatusCode,
289 response: reqwest::Response,
290) -> ApiError {
291 match status.as_u16() {
292 401 => ApiError::AuthenticationFailed,
293 403 => ApiError::AuthorizationFailed,
294 404 => ApiError::NotFound,
295 422 => {
296 let message = response
297 .text()
298 .await
299 .unwrap_or_else(|_| "Validation failed".to_string());
300 ApiError::InvalidRequest { message }
301 }
302 _ => {
303 let message = response
304 .text()
305 .await
306 .unwrap_or_else(|_| "Unknown error".to_string());
307 ApiError::HttpError {
308 status: status.as_u16(),
309 message,
310 }
311 }
312 }
313}
314
315pub use app::App;
316pub use commit::{
317 CommitDetails, CommitReference, Comparison, FileChange, FullCommit, GitSignature, Verification,
318};
319pub use installation::InstallationClient;
320pub use issue::{
321 Comment, CreateCommentRequest, CreateIssueRequest, CreateLabelRequest, CreateMilestoneRequest,
322 Issue, IssueActivityEvent, IssueRename, IssueUser, IssuesClient, Label, LabelsClient,
323 LockReason, Milestone, MilestoneSortField, MilestoneState, MilestoneSummary, MilestonesClient,
324 Reaction, ReactionContent, SortDirection, TimelineEvent, UpdateCommentRequest,
325 UpdateIssueRequest, UpdateLabelRequest, UpdateMilestoneRequest,
326};
327pub use pagination::{extract_page_number, parse_link_header, PagedResponse, Pagination};
328pub use project::{
329 AddProjectV2ItemRequest, ProjectOwner, ProjectV2, ProjectV2Item, ProjectsClient,
330};
331pub use pull_request::{
332 CreatePullRequestCommentRequest, CreatePullRequestRequest, CreateReviewRequest,
333 DismissReviewRequest, MergePullRequestRequest, MergeResult, PullRequest, PullRequestBranch,
334 PullRequestComment, PullRequestRepo, PullRequestsClient, Review,
335 UpdatePullRequestCommentRequest, UpdatePullRequestRequest, UpdateReviewRequest,
336};
337pub use rate_limit::{parse_rate_limit_from_headers, RateLimit, RateLimitContext, RateLimiter};
338pub use release::{
339 CreateReleaseRequest, Release, ReleaseAsset, ReleasesClient, UpdateReleaseRequest,
340};
341pub use repository::{
342 Branch, Commit, GitRef, OwnerType, RepositoriesClient, Repository, RepositoryOwner, Tag,
343};
344pub use retry::{
345 calculate_rate_limit_delay, detect_secondary_rate_limit, parse_retry_after, RateLimitInfo,
346 RetryPolicy,
347};
348pub use workflow::{
349 TriggerWorkflowRequest, Workflow, WorkflowRun, WorkflowRunConclusion, WorkflowRunStatus,
350 WorkflowState, WorkflowsClient,
351};
352
353/// Configuration for GitHub API client behavior.
354///
355/// Controls timeouts, retry behavior, rate limiting, and API endpoints.
356///
357/// # Examples
358///
359/// ```
360/// use github_bot_sdk::client::ClientConfig;
361/// use std::time::Duration;
362///
363/// let config = ClientConfig::default()
364/// .with_timeout(Duration::from_secs(60))
365/// .with_max_retries(5);
366/// ```
367#[derive(Debug, Clone)]
368pub struct ClientConfig {
369 /// User agent string for API requests (required by GitHub)
370 pub user_agent: String,
371 /// Request timeout duration
372 pub timeout: Duration,
373 /// Maximum number of retry attempts for transient failures
374 pub max_retries: u32,
375 /// Base delay for exponential backoff retries
376 pub initial_retry_delay: Duration,
377 /// Maximum delay between retries
378 pub max_retry_delay: Duration,
379 /// Rate limit safety margin (0.0 to 1.0) - buffer before hitting limits
380 pub rate_limit_margin: f64,
381 /// GitHub API base URL
382 pub github_api_url: String,
383}
384
385impl Default for ClientConfig {
386 fn default() -> Self {
387 Self {
388 user_agent: "github-bot-sdk/0.1.0".to_string(),
389 timeout: Duration::from_secs(30),
390 max_retries: 3,
391 initial_retry_delay: Duration::from_millis(100),
392 max_retry_delay: Duration::from_secs(60),
393 rate_limit_margin: 0.1, // Keep 10% buffer
394 github_api_url: "https://api.github.com".to_string(),
395 }
396 }
397}
398
399impl ClientConfig {
400 /// Create a new builder for client configuration.
401 pub fn builder() -> ClientConfigBuilder {
402 ClientConfigBuilder::new()
403 }
404
405 /// Set the user agent string.
406 pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
407 self.user_agent = user_agent.into();
408 self
409 }
410
411 /// Set the request timeout.
412 pub fn with_timeout(mut self, timeout: Duration) -> Self {
413 self.timeout = timeout;
414 self
415 }
416
417 /// Set the maximum number of retries.
418 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
419 self.max_retries = max_retries;
420 self
421 }
422
423 /// Set the rate limit safety margin.
424 pub fn with_rate_limit_margin(mut self, margin: f64) -> Self {
425 self.rate_limit_margin = margin.clamp(0.0, 1.0);
426 self
427 }
428
429 /// Set the GitHub API base URL.
430 pub fn with_github_api_url(mut self, url: impl Into<String>) -> Self {
431 self.github_api_url = url.into();
432 self
433 }
434}
435
436/// Builder for constructing `ClientConfig` instances.
437#[derive(Debug)]
438pub struct ClientConfigBuilder {
439 config: ClientConfig,
440}
441
442impl ClientConfigBuilder {
443 /// Create a new configuration builder with defaults.
444 pub fn new() -> Self {
445 Self {
446 config: ClientConfig::default(),
447 }
448 }
449
450 /// Set the user agent string.
451 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
452 self.config.user_agent = user_agent.into();
453 self
454 }
455
456 /// Set the request timeout.
457 pub fn timeout(mut self, timeout: Duration) -> Self {
458 self.config.timeout = timeout;
459 self
460 }
461
462 /// Set the maximum number of retries.
463 pub fn max_retries(mut self, max_retries: u32) -> Self {
464 self.config.max_retries = max_retries;
465 self
466 }
467
468 /// Set the rate limit safety margin.
469 pub fn rate_limit_margin(mut self, margin: f64) -> Self {
470 self.config.rate_limit_margin = margin.clamp(0.0, 1.0);
471 self
472 }
473
474 /// Set the GitHub API base URL.
475 pub fn github_api_url(mut self, url: impl Into<String>) -> Self {
476 self.config.github_api_url = url.into();
477 self
478 }
479
480 /// Build the final configuration.
481 pub fn build(self) -> ClientConfig {
482 self.config
483 }
484}
485
486impl Default for ClientConfigBuilder {
487 fn default() -> Self {
488 Self::new()
489 }
490}
491
492/// GitHub API client for authenticated operations.
493///
494/// The main client for interacting with GitHub's REST API. Handles authentication,
495/// rate limiting, retries, and provides both app-level and installation-level operations.
496///
497/// # Examples
498///
499/// ```no_run
500/// # use github_bot_sdk::client::{GitHubClient, ClientConfig};
501/// # use github_bot_sdk::auth::AuthenticationProvider;
502/// # async fn example(auth: impl AuthenticationProvider + 'static) -> Result<(), Box<dyn std::error::Error>> {
503/// let client = GitHubClient::builder(auth)
504/// .config(ClientConfig::default())
505/// .build()?;
506///
507/// // Get app information
508/// let app = client.get_app().await?;
509/// println!("App: {}", app.name);
510/// # Ok(())
511/// # }
512/// ```
513#[derive(Clone)]
514pub struct GitHubClient {
515 auth: Arc<dyn AuthenticationProvider>,
516 http_client: reqwest::Client,
517 config: ClientConfig,
518}
519
520impl GitHubClient {
521 /// Create a new builder for constructing a GitHub client.
522 ///
523 /// # Arguments
524 ///
525 /// * `auth` - Authentication provider for obtaining tokens
526 ///
527 /// # Examples
528 ///
529 /// ```no_run
530 /// # use github_bot_sdk::client::GitHubClient;
531 /// # use github_bot_sdk::auth::AuthenticationProvider;
532 /// # async fn example(auth: impl AuthenticationProvider + 'static) {
533 /// let client = GitHubClient::builder(auth).build().unwrap();
534 /// # }
535 /// ```
536 pub fn builder(auth: impl AuthenticationProvider + 'static) -> GitHubClientBuilder {
537 GitHubClientBuilder::new(auth)
538 }
539
540 /// Get the client configuration.
541 pub fn config(&self) -> &ClientConfig {
542 &self.config
543 }
544
545 /// Get the authentication provider.
546 pub fn auth_provider(&self) -> &dyn AuthenticationProvider {
547 self.auth.as_ref()
548 }
549
550 /// Get the HTTP client (internal use by InstallationClient).
551 pub(crate) fn http_client(&self) -> &reqwest::Client {
552 &self.http_client
553 }
554
555 // ========================================================================
556 // App-Level Operations (authenticated with JWT)
557 // ========================================================================
558
559 /// Get details about the authenticated GitHub App.
560 ///
561 /// Fetches metadata about the app including ID, name, owner, and permissions.
562 ///
563 /// # Authentication
564 ///
565 /// Requires app-level JWT authentication.
566 ///
567 /// # Examples
568 ///
569 /// ```no_run
570 /// # use github_bot_sdk::client::GitHubClient;
571 /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
572 /// let app = client.get_app().await?;
573 /// println!("App: {} (ID: {})", app.name, app.id);
574 /// # Ok(())
575 /// # }
576 /// ```
577 ///
578 /// # Errors
579 ///
580 /// Returns `ApiError` if:
581 /// - JWT generation fails
582 /// - HTTP request fails
583 /// - Response cannot be parsed
584 pub async fn get_app(&self) -> Result<App, ApiError> {
585 // Get JWT token from auth provider
586 let jwt = self
587 .auth
588 .app_token()
589 .await
590 .map_err(|e| ApiError::TokenGenerationFailed {
591 message: format!("Failed to generate JWT: {}", e),
592 })?;
593
594 // Build request URL
595 let url = format!("{}/app", self.config.github_api_url);
596
597 // Make authenticated request
598 let response = self
599 .http_client
600 .get(&url)
601 .header("Authorization", format!("Bearer {}", jwt.token()))
602 .header("Accept", "application/vnd.github+json")
603 .send()
604 .await
605 .map_err(|e| ApiError::Configuration {
606 message: format!("HTTP request failed: {}", e),
607 })?;
608
609 // Check for errors
610 if !response.status().is_success() {
611 let status = response.status();
612 let error_text = response
613 .text()
614 .await
615 .unwrap_or_else(|_| "Unable to read error body".to_string());
616 return Err(ApiError::Configuration {
617 message: format!("API request failed with status {}: {}", status, error_text),
618 });
619 }
620
621 // Parse response
622 let app = response
623 .json::<App>()
624 .await
625 .map_err(|e| ApiError::Configuration {
626 message: format!("Failed to parse App response: {}", e),
627 })?;
628
629 Ok(app)
630 }
631
632 /// List all installations for the authenticated GitHub App.
633 ///
634 /// Fetches all installations where this app is installed, including organizations
635 /// and user accounts.
636 ///
637 /// # Authentication
638 ///
639 /// Requires app-level JWT authentication.
640 ///
641 /// # Examples
642 ///
643 /// ```no_run
644 /// # use github_bot_sdk::client::GitHubClient;
645 /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
646 /// let installations = client.list_installations().await?;
647 /// for installation in installations {
648 /// println!("Installation ID: {} for {}", installation.id.as_u64(), installation.account.login);
649 /// }
650 /// # Ok(())
651 /// # }
652 /// ```
653 ///
654 /// # Errors
655 ///
656 /// Returns `ApiError` if:
657 /// - JWT generation fails
658 /// - HTTP request fails
659 /// - Response cannot be parsed
660 pub async fn list_installations(&self) -> Result<Vec<Installation>, ApiError> {
661 // Get JWT token from auth provider
662 let jwt = self
663 .auth
664 .app_token()
665 .await
666 .map_err(|e| ApiError::TokenGenerationFailed {
667 message: format!("Failed to generate JWT: {}", e),
668 })?;
669
670 // Build request URL
671 let url = format!("{}/app/installations", self.config.github_api_url);
672
673 // Make authenticated request
674 let response = self
675 .http_client
676 .get(&url)
677 .header("Authorization", format!("Bearer {}", jwt.token()))
678 .header("Accept", "application/vnd.github+json")
679 .send()
680 .await
681 .map_err(|e| ApiError::Configuration {
682 message: format!("HTTP request failed: {}", e),
683 })?;
684
685 // Check for errors
686 if !response.status().is_success() {
687 let status = response.status();
688 let error_text = response
689 .text()
690 .await
691 .unwrap_or_else(|_| "Unable to read error body".to_string());
692 return Err(ApiError::Configuration {
693 message: format!("API request failed with status {}: {}", status, error_text),
694 });
695 }
696
697 // Parse response
698 let installations =
699 response
700 .json::<Vec<Installation>>()
701 .await
702 .map_err(|e| ApiError::Configuration {
703 message: format!("Failed to parse installations response: {}", e),
704 })?;
705
706 Ok(installations)
707 }
708
709 /// Get a specific installation by ID.
710 ///
711 /// Fetches detailed information about a specific installation of this GitHub App.
712 ///
713 /// # Authentication
714 ///
715 /// Requires app-level JWT authentication.
716 ///
717 /// # Arguments
718 ///
719 /// * `installation_id` - The unique identifier for the installation
720 ///
721 /// # Examples
722 ///
723 /// ```no_run
724 /// # use github_bot_sdk::client::GitHubClient;
725 /// # use github_bot_sdk::auth::InstallationId;
726 /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
727 /// let installation_id = InstallationId::new(12345);
728 /// let installation = client.get_installation(installation_id).await?;
729 /// println!("Installation for: {}", installation.account.login);
730 /// # Ok(())
731 /// # }
732 /// ```
733 ///
734 /// # Errors
735 ///
736 /// Returns `ApiError` if:
737 /// - JWT generation fails
738 /// - HTTP request fails
739 /// - Installation not found (404)
740 /// - Response cannot be parsed
741 pub async fn get_installation(
742 &self,
743 installation_id: InstallationId,
744 ) -> Result<Installation, ApiError> {
745 // Get JWT token from auth provider
746 let jwt = self
747 .auth
748 .app_token()
749 .await
750 .map_err(|e| ApiError::TokenGenerationFailed {
751 message: format!("Failed to generate JWT: {}", e),
752 })?;
753
754 // Build request URL
755 let url = format!(
756 "{}/app/installations/{}",
757 self.config.github_api_url,
758 installation_id.as_u64()
759 );
760
761 // Make authenticated request
762 let response = self
763 .http_client
764 .get(&url)
765 .header("Authorization", format!("Bearer {}", jwt.token()))
766 .header("Accept", "application/vnd.github+json")
767 .send()
768 .await
769 .map_err(|e| ApiError::Configuration {
770 message: format!("HTTP request failed: {}", e),
771 })?;
772
773 // Check for errors
774 if !response.status().is_success() {
775 let status = response.status();
776 let error_text = response
777 .text()
778 .await
779 .unwrap_or_else(|_| "Unable to read error body".to_string());
780 return Err(ApiError::Configuration {
781 message: format!("API request failed with status {}: {}", status, error_text),
782 });
783 }
784
785 // Parse response
786 let installation =
787 response
788 .json::<Installation>()
789 .await
790 .map_err(|e| ApiError::Configuration {
791 message: format!("Failed to parse installation response: {}", e),
792 })?;
793
794 Ok(installation)
795 }
796
797 /// Make a raw authenticated GET request as the GitHub App.
798 ///
799 /// This is a generic method for making custom API requests that aren't covered
800 /// by the specific methods. Returns the raw response for flexible handling by the caller.
801 ///
802 /// # Authentication
803 ///
804 /// Requires app-level JWT authentication.
805 ///
806 /// # Arguments
807 ///
808 /// * `path` - The API path (e.g., "/app/installations" or "app/installations")
809 ///
810 /// # Examples
811 ///
812 /// ```no_run
813 /// # use github_bot_sdk::client::GitHubClient;
814 /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
815 /// // Make a custom GET request
816 /// let response = client.get_as_app("/app/hook/config").await?;
817 ///
818 /// if response.status().is_success() {
819 /// let data: serde_json::Value = response.json().await?;
820 /// println!("Response: {:?}", data);
821 /// }
822 /// # Ok(())
823 /// # }
824 /// ```
825 ///
826 /// # Errors
827 ///
828 /// Returns `ApiError` if:
829 /// - JWT generation fails
830 /// - HTTP request fails (network error, timeout, etc.)
831 ///
832 /// Note: Does NOT return an error for non-2xx status codes. The caller is responsible
833 /// for checking the response status.
834 pub async fn get_as_app(&self, path: &str) -> Result<reqwest::Response, ApiError> {
835 // Get JWT token from auth provider
836 let jwt = self
837 .auth
838 .app_token()
839 .await
840 .map_err(|e| ApiError::TokenGenerationFailed {
841 message: format!("Failed to generate JWT: {}", e),
842 })?;
843
844 // Normalize path - remove leading slash if present for consistent URL building
845 let normalized_path = path.strip_prefix('/').unwrap_or(path);
846
847 // Build request URL
848 let url = format!("{}/{}", self.config.github_api_url, normalized_path);
849
850 // Make authenticated request
851 let response = self
852 .http_client
853 .get(&url)
854 .header("Authorization", format!("Bearer {}", jwt.token()))
855 .header("Accept", "application/vnd.github+json")
856 .send()
857 .await
858 .map_err(|e| ApiError::Configuration {
859 message: format!("HTTP request failed: {}", e),
860 })?;
861
862 Ok(response)
863 }
864
865 /// Make a raw authenticated POST request as the GitHub App.
866 ///
867 /// This is a generic method for making custom API requests that aren't covered
868 /// by the specific methods. Returns the raw response for flexible handling by the caller.
869 ///
870 /// # Authentication
871 ///
872 /// Requires app-level JWT authentication.
873 ///
874 /// # Arguments
875 ///
876 /// * `path` - The API path (e.g., "/app/installations/{id}/suspended")
877 /// * `body` - The request body to serialize as JSON
878 ///
879 /// # Examples
880 ///
881 /// ```no_run
882 /// # use github_bot_sdk::client::GitHubClient;
883 /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
884 /// // Make a custom POST request
885 /// let body = serde_json::json!({"reason": "Violation of terms"});
886 /// let response = client.post_as_app("/app/installations/123/suspended", &body).await?;
887 ///
888 /// if response.status().is_success() {
889 /// println!("Installation suspended");
890 /// }
891 /// # Ok(())
892 /// # }
893 /// ```
894 ///
895 /// # Errors
896 ///
897 /// Returns `ApiError` if:
898 /// - JWT generation fails
899 /// - Body serialization fails
900 /// - HTTP request fails (network error, timeout, etc.)
901 ///
902 /// Note: Does NOT return an error for non-2xx status codes. The caller is responsible
903 /// for checking the response status.
904 pub async fn post_as_app(
905 &self,
906 path: &str,
907 body: &impl serde::Serialize,
908 ) -> Result<reqwest::Response, ApiError> {
909 // Get JWT token from auth provider
910 let jwt = self
911 .auth
912 .app_token()
913 .await
914 .map_err(|e| ApiError::TokenGenerationFailed {
915 message: format!("Failed to generate JWT: {}", e),
916 })?;
917
918 // Normalize path - remove leading slash if present for consistent URL building
919 let normalized_path = path.strip_prefix('/').unwrap_or(path);
920
921 // Build request URL
922 let url = format!("{}/{}", self.config.github_api_url, normalized_path);
923
924 // Make authenticated request with JSON body
925 let response = self
926 .http_client
927 .post(&url)
928 .header("Authorization", format!("Bearer {}", jwt.token()))
929 .header("Accept", "application/vnd.github+json")
930 .json(body)
931 .send()
932 .await
933 .map_err(|e| ApiError::Configuration {
934 message: format!("HTTP request failed: {}", e),
935 })?;
936
937 Ok(response)
938 }
939
940 // Installation-level operations will be implemented in task 5.0
941}
942
943impl std::fmt::Debug for GitHubClient {
944 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
945 f.debug_struct("GitHubClient")
946 .field("config", &self.config)
947 .field("auth", &"<AuthenticationProvider>")
948 .finish()
949 }
950}
951
952/// Builder for constructing `GitHubClient` instances.
953pub struct GitHubClientBuilder {
954 auth: Arc<dyn AuthenticationProvider>,
955 config: Option<ClientConfig>,
956}
957
958impl GitHubClientBuilder {
959 /// Create a new client builder.
960 fn new(auth: impl AuthenticationProvider + 'static) -> Self {
961 Self {
962 auth: Arc::new(auth),
963 config: None,
964 }
965 }
966
967 /// Set the client configuration.
968 ///
969 /// If not set, uses `ClientConfig::default()`.
970 pub fn config(mut self, config: ClientConfig) -> Self {
971 self.config = Some(config);
972 self
973 }
974
975 /// Build the GitHub client.
976 ///
977 /// # Errors
978 ///
979 /// Returns `ApiError::Configuration` if the HTTP client cannot be created.
980 pub fn build(self) -> Result<GitHubClient, ApiError> {
981 let config = self.config.unwrap_or_default();
982
983 // Build reqwest client with timeout and user agent
984 let http_client = reqwest::Client::builder()
985 .timeout(config.timeout)
986 .user_agent(&config.user_agent)
987 .build()
988 .map_err(|e| ApiError::Configuration {
989 message: format!("Failed to create HTTP client: {}", e),
990 })?;
991
992 Ok(GitHubClient {
993 auth: self.auth,
994 http_client,
995 config,
996 })
997 }
998}
999
1000#[cfg(test)]
1001#[path = "mod_tests.rs"]
1002mod tests;