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
277pub use app::App;
278pub use commit::{
279 CommitDetails, CommitReference, Comparison, FileChange, FullCommit, GitSignature, Verification,
280};
281pub use installation::InstallationClient;
282pub use issue::{
283 Comment, CreateCommentRequest, CreateIssueRequest, CreateLabelRequest, Issue, IssueUser, Label,
284 Milestone, SetIssueMilestoneRequest, UpdateCommentRequest, UpdateIssueRequest,
285 UpdateLabelRequest,
286};
287pub use pagination::{extract_page_number, parse_link_header, PagedResponse, Pagination};
288pub use project::{AddProjectV2ItemRequest, ProjectOwner, ProjectV2, ProjectV2Item};
289pub use pull_request::{
290 CreatePullRequestCommentRequest, CreatePullRequestRequest, CreateReviewRequest,
291 DismissReviewRequest, MergePullRequestRequest, MergeResult, PullRequest, PullRequestBranch,
292 PullRequestComment, PullRequestRepo, Review, SetPullRequestMilestoneRequest,
293 UpdatePullRequestRequest, UpdateReviewRequest,
294};
295pub use rate_limit::{parse_rate_limit_from_headers, RateLimit, RateLimitContext, RateLimiter};
296pub use release::{CreateReleaseRequest, Release, ReleaseAsset, UpdateReleaseRequest};
297pub use repository::{Branch, Commit, GitRef, OwnerType, Repository, RepositoryOwner, Tag};
298pub use retry::{
299 calculate_rate_limit_delay, detect_secondary_rate_limit, parse_retry_after, RateLimitInfo,
300 RetryPolicy,
301};
302pub use workflow::{TriggerWorkflowRequest, Workflow, WorkflowRun};
303
304/// Configuration for GitHub API client behavior.
305///
306/// Controls timeouts, retry behavior, rate limiting, and API endpoints.
307///
308/// # Examples
309///
310/// ```
311/// use github_bot_sdk::client::ClientConfig;
312/// use std::time::Duration;
313///
314/// let config = ClientConfig::default()
315/// .with_timeout(Duration::from_secs(60))
316/// .with_max_retries(5);
317/// ```
318#[derive(Debug, Clone)]
319pub struct ClientConfig {
320 /// User agent string for API requests (required by GitHub)
321 pub user_agent: String,
322 /// Request timeout duration
323 pub timeout: Duration,
324 /// Maximum number of retry attempts for transient failures
325 pub max_retries: u32,
326 /// Base delay for exponential backoff retries
327 pub initial_retry_delay: Duration,
328 /// Maximum delay between retries
329 pub max_retry_delay: Duration,
330 /// Rate limit safety margin (0.0 to 1.0) - buffer before hitting limits
331 pub rate_limit_margin: f64,
332 /// GitHub API base URL
333 pub github_api_url: String,
334}
335
336impl Default for ClientConfig {
337 fn default() -> Self {
338 Self {
339 user_agent: "github-bot-sdk/0.1.0".to_string(),
340 timeout: Duration::from_secs(30),
341 max_retries: 3,
342 initial_retry_delay: Duration::from_millis(100),
343 max_retry_delay: Duration::from_secs(60),
344 rate_limit_margin: 0.1, // Keep 10% buffer
345 github_api_url: "https://api.github.com".to_string(),
346 }
347 }
348}
349
350impl ClientConfig {
351 /// Create a new builder for client configuration.
352 pub fn builder() -> ClientConfigBuilder {
353 ClientConfigBuilder::new()
354 }
355
356 /// Set the user agent string.
357 pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
358 self.user_agent = user_agent.into();
359 self
360 }
361
362 /// Set the request timeout.
363 pub fn with_timeout(mut self, timeout: Duration) -> Self {
364 self.timeout = timeout;
365 self
366 }
367
368 /// Set the maximum number of retries.
369 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
370 self.max_retries = max_retries;
371 self
372 }
373
374 /// Set the rate limit safety margin.
375 pub fn with_rate_limit_margin(mut self, margin: f64) -> Self {
376 self.rate_limit_margin = margin.clamp(0.0, 1.0);
377 self
378 }
379
380 /// Set the GitHub API base URL.
381 pub fn with_github_api_url(mut self, url: impl Into<String>) -> Self {
382 self.github_api_url = url.into();
383 self
384 }
385}
386
387/// Builder for constructing `ClientConfig` instances.
388#[derive(Debug)]
389pub struct ClientConfigBuilder {
390 config: ClientConfig,
391}
392
393impl ClientConfigBuilder {
394 /// Create a new configuration builder with defaults.
395 pub fn new() -> Self {
396 Self {
397 config: ClientConfig::default(),
398 }
399 }
400
401 /// Set the user agent string.
402 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
403 self.config.user_agent = user_agent.into();
404 self
405 }
406
407 /// Set the request timeout.
408 pub fn timeout(mut self, timeout: Duration) -> Self {
409 self.config.timeout = timeout;
410 self
411 }
412
413 /// Set the maximum number of retries.
414 pub fn max_retries(mut self, max_retries: u32) -> Self {
415 self.config.max_retries = max_retries;
416 self
417 }
418
419 /// Set the rate limit safety margin.
420 pub fn rate_limit_margin(mut self, margin: f64) -> Self {
421 self.config.rate_limit_margin = margin.clamp(0.0, 1.0);
422 self
423 }
424
425 /// Set the GitHub API base URL.
426 pub fn github_api_url(mut self, url: impl Into<String>) -> Self {
427 self.config.github_api_url = url.into();
428 self
429 }
430
431 /// Build the final configuration.
432 pub fn build(self) -> ClientConfig {
433 self.config
434 }
435}
436
437impl Default for ClientConfigBuilder {
438 fn default() -> Self {
439 Self::new()
440 }
441}
442
443/// GitHub API client for authenticated operations.
444///
445/// The main client for interacting with GitHub's REST API. Handles authentication,
446/// rate limiting, retries, and provides both app-level and installation-level operations.
447///
448/// # Examples
449///
450/// ```no_run
451/// # use github_bot_sdk::client::{GitHubClient, ClientConfig};
452/// # use github_bot_sdk::auth::AuthenticationProvider;
453/// # async fn example(auth: impl AuthenticationProvider + 'static) -> Result<(), Box<dyn std::error::Error>> {
454/// let client = GitHubClient::builder(auth)
455/// .config(ClientConfig::default())
456/// .build()?;
457///
458/// // Get app information
459/// let app = client.get_app().await?;
460/// println!("App: {}", app.name);
461/// # Ok(())
462/// # }
463/// ```
464#[derive(Clone)]
465pub struct GitHubClient {
466 auth: Arc<dyn AuthenticationProvider>,
467 http_client: reqwest::Client,
468 config: ClientConfig,
469}
470
471impl GitHubClient {
472 /// Create a new builder for constructing a GitHub client.
473 ///
474 /// # Arguments
475 ///
476 /// * `auth` - Authentication provider for obtaining tokens
477 ///
478 /// # Examples
479 ///
480 /// ```no_run
481 /// # use github_bot_sdk::client::GitHubClient;
482 /// # use github_bot_sdk::auth::AuthenticationProvider;
483 /// # async fn example(auth: impl AuthenticationProvider + 'static) {
484 /// let client = GitHubClient::builder(auth).build().unwrap();
485 /// # }
486 /// ```
487 pub fn builder(auth: impl AuthenticationProvider + 'static) -> GitHubClientBuilder {
488 GitHubClientBuilder::new(auth)
489 }
490
491 /// Get the client configuration.
492 pub fn config(&self) -> &ClientConfig {
493 &self.config
494 }
495
496 /// Get the authentication provider.
497 pub fn auth_provider(&self) -> &dyn AuthenticationProvider {
498 self.auth.as_ref()
499 }
500
501 /// Get the HTTP client (internal use by InstallationClient).
502 pub(crate) fn http_client(&self) -> &reqwest::Client {
503 &self.http_client
504 }
505
506 // ========================================================================
507 // App-Level Operations (authenticated with JWT)
508 // ========================================================================
509
510 /// Get details about the authenticated GitHub App.
511 ///
512 /// Fetches metadata about the app including ID, name, owner, and permissions.
513 ///
514 /// # Authentication
515 ///
516 /// Requires app-level JWT authentication.
517 ///
518 /// # Examples
519 ///
520 /// ```no_run
521 /// # use github_bot_sdk::client::GitHubClient;
522 /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
523 /// let app = client.get_app().await?;
524 /// println!("App: {} (ID: {})", app.name, app.id);
525 /// # Ok(())
526 /// # }
527 /// ```
528 ///
529 /// # Errors
530 ///
531 /// Returns `ApiError` if:
532 /// - JWT generation fails
533 /// - HTTP request fails
534 /// - Response cannot be parsed
535 pub async fn get_app(&self) -> Result<App, ApiError> {
536 // Get JWT token from auth provider
537 let jwt = self
538 .auth
539 .app_token()
540 .await
541 .map_err(|e| ApiError::TokenGenerationFailed {
542 message: format!("Failed to generate JWT: {}", e),
543 })?;
544
545 // Build request URL
546 let url = format!("{}/app", self.config.github_api_url);
547
548 // Make authenticated request
549 let response = self
550 .http_client
551 .get(&url)
552 .header("Authorization", format!("Bearer {}", jwt.token()))
553 .header("Accept", "application/vnd.github+json")
554 .send()
555 .await
556 .map_err(|e| ApiError::Configuration {
557 message: format!("HTTP request failed: {}", e),
558 })?;
559
560 // Check for errors
561 if !response.status().is_success() {
562 let status = response.status();
563 let error_text = response
564 .text()
565 .await
566 .unwrap_or_else(|_| "Unable to read error body".to_string());
567 return Err(ApiError::Configuration {
568 message: format!("API request failed with status {}: {}", status, error_text),
569 });
570 }
571
572 // Parse response
573 let app = response
574 .json::<App>()
575 .await
576 .map_err(|e| ApiError::Configuration {
577 message: format!("Failed to parse App response: {}", e),
578 })?;
579
580 Ok(app)
581 }
582
583 /// List all installations for the authenticated GitHub App.
584 ///
585 /// Fetches all installations where this app is installed, including organizations
586 /// and user accounts.
587 ///
588 /// # Authentication
589 ///
590 /// Requires app-level JWT authentication.
591 ///
592 /// # Examples
593 ///
594 /// ```no_run
595 /// # use github_bot_sdk::client::GitHubClient;
596 /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
597 /// let installations = client.list_installations().await?;
598 /// for installation in installations {
599 /// println!("Installation ID: {} for {}", installation.id.as_u64(), installation.account.login);
600 /// }
601 /// # Ok(())
602 /// # }
603 /// ```
604 ///
605 /// # Errors
606 ///
607 /// Returns `ApiError` if:
608 /// - JWT generation fails
609 /// - HTTP request fails
610 /// - Response cannot be parsed
611 pub async fn list_installations(&self) -> Result<Vec<Installation>, ApiError> {
612 // Get JWT token from auth provider
613 let jwt = self
614 .auth
615 .app_token()
616 .await
617 .map_err(|e| ApiError::TokenGenerationFailed {
618 message: format!("Failed to generate JWT: {}", e),
619 })?;
620
621 // Build request URL
622 let url = format!("{}/app/installations", self.config.github_api_url);
623
624 // Make authenticated request
625 let response = self
626 .http_client
627 .get(&url)
628 .header("Authorization", format!("Bearer {}", jwt.token()))
629 .header("Accept", "application/vnd.github+json")
630 .send()
631 .await
632 .map_err(|e| ApiError::Configuration {
633 message: format!("HTTP request failed: {}", e),
634 })?;
635
636 // Check for errors
637 if !response.status().is_success() {
638 let status = response.status();
639 let error_text = response
640 .text()
641 .await
642 .unwrap_or_else(|_| "Unable to read error body".to_string());
643 return Err(ApiError::Configuration {
644 message: format!("API request failed with status {}: {}", status, error_text),
645 });
646 }
647
648 // Parse response
649 let installations =
650 response
651 .json::<Vec<Installation>>()
652 .await
653 .map_err(|e| ApiError::Configuration {
654 message: format!("Failed to parse installations response: {}", e),
655 })?;
656
657 Ok(installations)
658 }
659
660 /// Get a specific installation by ID.
661 ///
662 /// Fetches detailed information about a specific installation of this GitHub App.
663 ///
664 /// # Authentication
665 ///
666 /// Requires app-level JWT authentication.
667 ///
668 /// # Arguments
669 ///
670 /// * `installation_id` - The unique identifier for the installation
671 ///
672 /// # Examples
673 ///
674 /// ```no_run
675 /// # use github_bot_sdk::client::GitHubClient;
676 /// # use github_bot_sdk::auth::InstallationId;
677 /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
678 /// let installation_id = InstallationId::new(12345);
679 /// let installation = client.get_installation(installation_id).await?;
680 /// println!("Installation for: {}", installation.account.login);
681 /// # Ok(())
682 /// # }
683 /// ```
684 ///
685 /// # Errors
686 ///
687 /// Returns `ApiError` if:
688 /// - JWT generation fails
689 /// - HTTP request fails
690 /// - Installation not found (404)
691 /// - Response cannot be parsed
692 pub async fn get_installation(
693 &self,
694 installation_id: InstallationId,
695 ) -> Result<Installation, ApiError> {
696 // Get JWT token from auth provider
697 let jwt = self
698 .auth
699 .app_token()
700 .await
701 .map_err(|e| ApiError::TokenGenerationFailed {
702 message: format!("Failed to generate JWT: {}", e),
703 })?;
704
705 // Build request URL
706 let url = format!(
707 "{}/app/installations/{}",
708 self.config.github_api_url,
709 installation_id.as_u64()
710 );
711
712 // Make authenticated request
713 let response = self
714 .http_client
715 .get(&url)
716 .header("Authorization", format!("Bearer {}", jwt.token()))
717 .header("Accept", "application/vnd.github+json")
718 .send()
719 .await
720 .map_err(|e| ApiError::Configuration {
721 message: format!("HTTP request failed: {}", e),
722 })?;
723
724 // Check for errors
725 if !response.status().is_success() {
726 let status = response.status();
727 let error_text = response
728 .text()
729 .await
730 .unwrap_or_else(|_| "Unable to read error body".to_string());
731 return Err(ApiError::Configuration {
732 message: format!("API request failed with status {}: {}", status, error_text),
733 });
734 }
735
736 // Parse response
737 let installation =
738 response
739 .json::<Installation>()
740 .await
741 .map_err(|e| ApiError::Configuration {
742 message: format!("Failed to parse installation response: {}", e),
743 })?;
744
745 Ok(installation)
746 }
747
748 /// Make a raw authenticated GET request as the GitHub App.
749 ///
750 /// This is a generic method for making custom API requests that aren't covered
751 /// by the specific methods. Returns the raw response for flexible handling by the caller.
752 ///
753 /// # Authentication
754 ///
755 /// Requires app-level JWT authentication.
756 ///
757 /// # Arguments
758 ///
759 /// * `path` - The API path (e.g., "/app/installations" or "app/installations")
760 ///
761 /// # Examples
762 ///
763 /// ```no_run
764 /// # use github_bot_sdk::client::GitHubClient;
765 /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
766 /// // Make a custom GET request
767 /// let response = client.get_as_app("/app/hook/config").await?;
768 ///
769 /// if response.status().is_success() {
770 /// let data: serde_json::Value = response.json().await?;
771 /// println!("Response: {:?}", data);
772 /// }
773 /// # Ok(())
774 /// # }
775 /// ```
776 ///
777 /// # Errors
778 ///
779 /// Returns `ApiError` if:
780 /// - JWT generation fails
781 /// - HTTP request fails (network error, timeout, etc.)
782 ///
783 /// Note: Does NOT return an error for non-2xx status codes. The caller is responsible
784 /// for checking the response status.
785 pub async fn get_as_app(&self, path: &str) -> Result<reqwest::Response, ApiError> {
786 // Get JWT token from auth provider
787 let jwt = self
788 .auth
789 .app_token()
790 .await
791 .map_err(|e| ApiError::TokenGenerationFailed {
792 message: format!("Failed to generate JWT: {}", e),
793 })?;
794
795 // Normalize path - remove leading slash if present for consistent URL building
796 let normalized_path = path.strip_prefix('/').unwrap_or(path);
797
798 // Build request URL
799 let url = format!("{}/{}", self.config.github_api_url, normalized_path);
800
801 // Make authenticated request
802 let response = self
803 .http_client
804 .get(&url)
805 .header("Authorization", format!("Bearer {}", jwt.token()))
806 .header("Accept", "application/vnd.github+json")
807 .send()
808 .await
809 .map_err(|e| ApiError::Configuration {
810 message: format!("HTTP request failed: {}", e),
811 })?;
812
813 Ok(response)
814 }
815
816 /// Make a raw authenticated POST request as the GitHub App.
817 ///
818 /// This is a generic method for making custom API requests that aren't covered
819 /// by the specific methods. Returns the raw response for flexible handling by the caller.
820 ///
821 /// # Authentication
822 ///
823 /// Requires app-level JWT authentication.
824 ///
825 /// # Arguments
826 ///
827 /// * `path` - The API path (e.g., "/app/installations/{id}/suspended")
828 /// * `body` - The request body to serialize as JSON
829 ///
830 /// # Examples
831 ///
832 /// ```no_run
833 /// # use github_bot_sdk::client::GitHubClient;
834 /// # async fn example(client: &GitHubClient) -> Result<(), Box<dyn std::error::Error>> {
835 /// // Make a custom POST request
836 /// let body = serde_json::json!({"reason": "Violation of terms"});
837 /// let response = client.post_as_app("/app/installations/123/suspended", &body).await?;
838 ///
839 /// if response.status().is_success() {
840 /// println!("Installation suspended");
841 /// }
842 /// # Ok(())
843 /// # }
844 /// ```
845 ///
846 /// # Errors
847 ///
848 /// Returns `ApiError` if:
849 /// - JWT generation fails
850 /// - Body serialization fails
851 /// - HTTP request fails (network error, timeout, etc.)
852 ///
853 /// Note: Does NOT return an error for non-2xx status codes. The caller is responsible
854 /// for checking the response status.
855 pub async fn post_as_app(
856 &self,
857 path: &str,
858 body: &impl serde::Serialize,
859 ) -> Result<reqwest::Response, ApiError> {
860 // Get JWT token from auth provider
861 let jwt = self
862 .auth
863 .app_token()
864 .await
865 .map_err(|e| ApiError::TokenGenerationFailed {
866 message: format!("Failed to generate JWT: {}", e),
867 })?;
868
869 // Normalize path - remove leading slash if present for consistent URL building
870 let normalized_path = path.strip_prefix('/').unwrap_or(path);
871
872 // Build request URL
873 let url = format!("{}/{}", self.config.github_api_url, normalized_path);
874
875 // Make authenticated request with JSON body
876 let response = self
877 .http_client
878 .post(&url)
879 .header("Authorization", format!("Bearer {}", jwt.token()))
880 .header("Accept", "application/vnd.github+json")
881 .json(body)
882 .send()
883 .await
884 .map_err(|e| ApiError::Configuration {
885 message: format!("HTTP request failed: {}", e),
886 })?;
887
888 Ok(response)
889 }
890
891 // Installation-level operations will be implemented in task 5.0
892}
893
894impl std::fmt::Debug for GitHubClient {
895 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
896 f.debug_struct("GitHubClient")
897 .field("config", &self.config)
898 .field("auth", &"<AuthenticationProvider>")
899 .finish()
900 }
901}
902
903/// Builder for constructing `GitHubClient` instances.
904pub struct GitHubClientBuilder {
905 auth: Arc<dyn AuthenticationProvider>,
906 config: Option<ClientConfig>,
907}
908
909impl GitHubClientBuilder {
910 /// Create a new client builder.
911 fn new(auth: impl AuthenticationProvider + 'static) -> Self {
912 Self {
913 auth: Arc::new(auth),
914 config: None,
915 }
916 }
917
918 /// Set the client configuration.
919 ///
920 /// If not set, uses `ClientConfig::default()`.
921 pub fn config(mut self, config: ClientConfig) -> Self {
922 self.config = Some(config);
923 self
924 }
925
926 /// Build the GitHub client.
927 ///
928 /// # Errors
929 ///
930 /// Returns `ApiError::Configuration` if the HTTP client cannot be created.
931 pub fn build(self) -> Result<GitHubClient, ApiError> {
932 let config = self.config.unwrap_or_default();
933
934 // Build reqwest client with timeout and user agent
935 let http_client = reqwest::Client::builder()
936 .timeout(config.timeout)
937 .user_agent(&config.user_agent)
938 .build()
939 .map_err(|e| ApiError::Configuration {
940 message: format!("Failed to create HTTP client: {}", e),
941 })?;
942
943 Ok(GitHubClient {
944 auth: self.auth,
945 http_client,
946 config,
947 })
948 }
949}
950
951#[cfg(test)]
952#[path = "mod_tests.rs"]
953mod tests;