clawspec_core/client/
builder.rs

1use std::fmt::Debug;
2use std::net::{IpAddr, Ipv4Addr};
3
4use http::Uri;
5use http::uri::{PathAndQuery, Scheme};
6use indexmap::IndexMap;
7use utoipa::openapi::{Info, Server};
8
9use super::openapi::channel::CollectorHandle;
10use super::security::{SecurityRequirement, SecurityScheme};
11use super::{ApiClient, ApiClientError};
12
13/// Builder for creating `ApiClient` instances with comprehensive configuration options.
14///
15/// `ApiClientBuilder` provides a fluent interface for configuring all aspects of an API client,
16/// including network settings, base paths, OpenAPI metadata, and server definitions.
17///
18/// # Default Configuration
19///
20/// - **Scheme**: HTTP (use `with_scheme()` to change to HTTPS)
21/// - **Host**: 127.0.0.1 (localhost)
22/// - **Port**: 80 (standard HTTP port)
23/// - **Base path**: None (requests go to root path)
24/// - **OpenAPI info**: None (no metadata)
25/// - **Servers**: Empty list
26///
27/// # Example
28///
29/// ```rust
30/// use clawspec_core::ApiClient;
31/// use http::uri::Scheme;
32/// use utoipa::openapi::{InfoBuilder, ServerBuilder};
33///
34/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
35/// let client = ApiClient::builder()
36///     .with_scheme(Scheme::HTTPS)
37///     .with_host("api.example.com")
38///     .with_port(443)
39///     .with_base_path("/v1")?
40///     .with_info(
41///         InfoBuilder::new()
42///             .title("Example API")
43///             .version("1.0.0")
44///             .description(Some("API documentation generated from tests"))
45///             .build()
46///     )
47///     .add_server(
48///         ServerBuilder::new()
49///             .url("https://api.example.com/v1")
50///             .description(Some("Production server"))
51///             .build()
52///     )
53///     .add_server(
54///         ServerBuilder::new()
55///             .url("https://staging.example.com/v1")
56///             .description(Some("Staging server"))
57///             .build()
58///     )
59///     .build()?;
60/// # Ok(())
61/// # }
62/// ```
63#[derive(Debug, Clone)]
64pub struct ApiClientBuilder {
65    client: reqwest::Client,
66    scheme: Scheme,
67    host: String,
68    port: u16,
69    base_path: Option<PathAndQuery>,
70    info: Option<Info>,
71    servers: Vec<Server>,
72    authentication: Option<super::Authentication>,
73    security_schemes: IndexMap<String, SecurityScheme>,
74    default_security: Vec<SecurityRequirement>,
75}
76
77impl ApiClientBuilder {
78    /// Builds the final `ApiClient` instance with all configured settings.
79    ///
80    /// This method consumes the builder and creates an `ApiClient` ready for making API calls.
81    /// All configuration options set through the builder methods are applied to the client.
82    ///
83    /// # Returns
84    ///
85    /// Returns a `Result<ApiClient, ApiClientError>` which will be:
86    /// - `Ok(ApiClient)` if the client was created successfully
87    /// - `Err(ApiClientError)` if there was an error building the URI or other configuration issues
88    ///
89    /// # Errors
90    ///
91    /// This method can fail if:
92    /// - The base URI cannot be constructed from the provided scheme, host, and port
93    /// - The base path is invalid and cannot be parsed
94    ///
95    /// # Example
96    ///
97    /// ```rust
98    /// use clawspec_core::ApiClient;
99    ///
100    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
101    /// let client = ApiClient::builder()
102    ///     .with_host("api.example.com")
103    ///     .with_base_path("/v1")?
104    ///     .build()?;  // This consumes the builder
105    ///
106    /// // Now you can use the client for API calls
107    /// # Ok(())
108    /// # }
109    /// ```
110    pub fn build(self) -> Result<ApiClient, ApiClientError> {
111        let Self {
112            client,
113            scheme,
114            host,
115            port,
116            base_path,
117            info,
118            servers,
119            authentication,
120            security_schemes,
121            default_security,
122        } = self;
123
124        let builder = Uri::builder()
125            .scheme(scheme)
126            .authority(format!("{host}:{port}"));
127        let builder = if let Some(path) = &base_path {
128            builder.path_and_query(path.path())
129        } else {
130            builder.path_and_query("/")
131        };
132
133        let base_uri = builder.build()?;
134        let base_path = base_path
135            .as_ref()
136            .map(|it| it.path().to_string())
137            .unwrap_or_default();
138
139        let collector_handle = CollectorHandle::spawn();
140
141        Ok(ApiClient {
142            client,
143            base_uri,
144            base_path,
145            info,
146            servers,
147            collector_handle,
148            authentication,
149            security_schemes,
150            default_security,
151        })
152    }
153
154    /// Sets the HTTP scheme. Defaults to `HTTP`. Use [`with_https()`](Self::with_https) for convenience.
155    pub fn with_scheme(mut self, scheme: Scheme) -> Self {
156        self.scheme = scheme;
157        self
158    }
159
160    /// Sets the hostname or IP address. Defaults to `127.0.0.1`.
161    pub fn with_host(mut self, host: impl Into<String>) -> Self {
162        self.host = host.into();
163        self
164    }
165
166    /// Sets the port number. Defaults to `80`.
167    pub fn with_port(mut self, port: u16) -> Self {
168        self.port = port;
169        self
170    }
171
172    /// Sets the base path prepended to all requests (e.g., `/v1` or `/api/v2`).
173    ///
174    /// # Errors
175    ///
176    /// Returns [`ApiClientError::InvalidBasePath`] if the path is invalid.
177    pub fn with_base_path<P>(mut self, base_path: P) -> Result<Self, ApiClientError>
178    where
179        P: TryInto<PathAndQuery>,
180        P::Error: Debug + 'static,
181    {
182        let base_path = base_path
183            .try_into()
184            .map_err(|err| ApiClientError::InvalidBasePath {
185                error: format!("{err:?}"),
186            })?;
187        self.base_path = Some(base_path);
188        Ok(self)
189    }
190
191    /// Sets the OpenAPI info metadata (title, version, description, etc.).
192    ///
193    /// Use [`with_info_simple()`](Self::with_info_simple) for basic cases.
194    pub fn with_info(mut self, info: Info) -> Self {
195        self.info = Some(info);
196        self
197    }
198
199    /// Sets the OpenAPI servers list. Use [`add_server()`](Self::add_server) to add incrementally.
200    pub fn with_servers(mut self, servers: Vec<Server>) -> Self {
201        self.servers = servers;
202        self
203    }
204
205    /// Adds a server to the OpenAPI specification. Use [`add_server_simple()`](Self::add_server_simple) for convenience.
206    pub fn add_server(mut self, server: Server) -> Self {
207        self.servers.push(server);
208        self
209    }
210
211    /// Sets the default authentication for all requests. Can be overridden per-request.
212    ///
213    /// Supports `Bearer`, `Basic`, and `ApiKey` authentication types.
214    pub fn with_authentication(mut self, authentication: super::Authentication) -> Self {
215        self.authentication = Some(authentication);
216        self
217    }
218
219    // =========================================================================
220    // Simplified builder methods (no external types required)
221    // =========================================================================
222
223    /// Convenience method to set API title and version without importing utoipa types.
224    pub fn with_info_simple(
225        mut self,
226        title: impl Into<String>,
227        version: impl Into<String>,
228    ) -> Self {
229        use utoipa::openapi::InfoBuilder;
230        self.info = Some(InfoBuilder::new().title(title).version(version).build());
231        self
232    }
233
234    /// Sets or updates the API description. Creates default info if not set.
235    pub fn with_description(mut self, description: impl Into<String>) -> Self {
236        use utoipa::openapi::InfoBuilder;
237        let description = description.into();
238        self.info = Some(match self.info {
239            Some(info) => InfoBuilder::from(info)
240                .description(Some(description))
241                .build(),
242            None => InfoBuilder::new()
243                .title("API")
244                .version("0.0.0")
245                .description(Some(description))
246                .build(),
247        });
248        self
249    }
250
251    /// Sets the HTTP scheme to HTTPS. Convenience method for `with_scheme(Scheme::HTTPS)`.
252    pub fn with_https(mut self) -> Self {
253        self.scheme = Scheme::HTTPS;
254        self
255    }
256
257    /// Adds a server with URL and description. No utoipa imports needed.
258    pub fn add_server_simple(
259        mut self,
260        url: impl Into<String>,
261        description: impl Into<String>,
262    ) -> Self {
263        use utoipa::openapi::ServerBuilder;
264        let server = ServerBuilder::new()
265            .url(url)
266            .description(Some(description))
267            .build();
268        self.servers.push(server);
269        self
270    }
271
272    /// Registers a named security scheme for OpenAPI `components.securitySchemes`.
273    pub fn with_security_scheme(mut self, name: impl Into<String>, scheme: SecurityScheme) -> Self {
274        self.security_schemes.insert(name.into(), scheme);
275        self
276    }
277
278    /// Sets the default security requirement for all operations.
279    pub fn with_default_security(mut self, requirement: SecurityRequirement) -> Self {
280        self.default_security.push(requirement);
281        self
282    }
283
284    /// Adds multiple default security requirements (OR relationship).
285    pub fn with_default_securities(
286        mut self,
287        requirements: impl IntoIterator<Item = SecurityRequirement>,
288    ) -> Self {
289        self.default_security.extend(requirements);
290        self
291    }
292
293    // =========================================================================
294    // OAuth2 convenience methods (requires "oauth2" feature)
295    // =========================================================================
296
297    /// Configures OAuth2 Client Credentials authentication. Tokens are auto-refreshed.
298    ///
299    /// # Errors
300    ///
301    /// Returns an error if the token URL is invalid.
302    ///
303    /// ```rust,ignore
304    /// let client = ApiClient::builder()
305    ///     .with_oauth2_client_credentials("client-id", "secret", "https://auth.example.com/token")?
306    ///     .build()?;
307    /// # Ok(())
308    /// # }
309    /// ```
310    #[cfg(feature = "oauth2")]
311    pub fn with_oauth2_client_credentials(
312        self,
313        client_id: impl Into<String>,
314        client_secret: impl Into<super::SecureString>,
315        token_url: impl AsRef<str>,
316    ) -> Result<Self, ApiClientError> {
317        use super::Authentication;
318        use super::oauth2::{OAuth2Config, SharedOAuth2Config};
319
320        let config = OAuth2Config::client_credentials(client_id, client_secret, token_url)
321            .map_err(ApiClientError::oauth2_error)?
322            .build()
323            .map_err(ApiClientError::oauth2_error)?;
324
325        Ok(self.with_authentication(Authentication::OAuth2(SharedOAuth2Config::new(config))))
326    }
327
328    /// Configures OAuth2 authentication with Client Credentials flow and scopes.
329    ///
330    /// This is a convenience method for setting up OAuth2 authentication with specific scopes.
331    ///
332    /// # Parameters
333    ///
334    /// * `client_id` - The OAuth2 client ID
335    /// * `client_secret` - The OAuth2 client secret
336    /// * `token_url` - The token endpoint URL
337    /// * `scopes` - The OAuth2 scopes to request
338    ///
339    /// # Example
340    ///
341    /// ```rust,ignore
342    /// use clawspec_core::ApiClient;
343    ///
344    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
345    /// let client = ApiClient::builder()
346    ///     .with_oauth2_client_credentials_scopes(
347    ///         "my-client-id",
348    ///         "my-client-secret",
349    ///         "https://auth.example.com/oauth/token",
350    ///         ["read:users", "write:users"],
351    ///     )?
352    ///     .build()?;
353    /// # Ok(())
354    /// # }
355    /// ```
356    #[cfg(feature = "oauth2")]
357    pub fn with_oauth2_client_credentials_scopes(
358        self,
359        client_id: impl Into<String>,
360        client_secret: impl Into<super::SecureString>,
361        token_url: impl AsRef<str>,
362        scopes: impl IntoIterator<Item = impl Into<String>>,
363    ) -> Result<Self, ApiClientError> {
364        use super::Authentication;
365        use super::oauth2::{OAuth2Config, SharedOAuth2Config};
366
367        let config = OAuth2Config::client_credentials(client_id, client_secret, token_url)
368            .map_err(ApiClientError::oauth2_error)?
369            .add_scopes(scopes)
370            .build()
371            .map_err(ApiClientError::oauth2_error)?;
372
373        Ok(self.with_authentication(Authentication::OAuth2(SharedOAuth2Config::new(config))))
374    }
375
376    /// Configures OAuth2 authentication with a pre-acquired token.
377    ///
378    /// Use this method when you already have an access token from another source
379    /// (e.g., environment variable, test setup).
380    ///
381    /// # Parameters
382    ///
383    /// * `access_token` - The pre-acquired access token
384    ///
385    /// # Example
386    ///
387    /// ```rust,ignore
388    /// use clawspec_core::ApiClient;
389    ///
390    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
391    /// let token = std::env::var("API_TOKEN").unwrap_or_else(|_| "test-token".to_string());
392    ///
393    /// let client = ApiClient::builder()
394    ///     .with_oauth2_token(token)?
395    ///     .build()?;
396    /// # Ok(())
397    /// # }
398    /// ```
399    #[cfg(feature = "oauth2")]
400    pub fn with_oauth2_token(
401        self,
402        access_token: impl Into<String>,
403    ) -> Result<Self, ApiClientError> {
404        use super::Authentication;
405        use super::oauth2::{OAuth2Config, SharedOAuth2Config};
406
407        // Use a dummy token URL for pre-acquired tokens
408        let config = OAuth2Config::pre_acquired(
409            "pre-acquired",
410            "https://placeholder.example.com/token",
411            access_token,
412        )
413        .map_err(ApiClientError::oauth2_error)?
414        .build()
415        .map_err(ApiClientError::oauth2_error)?;
416
417        Ok(self.with_authentication(Authentication::OAuth2(SharedOAuth2Config::new(config))))
418    }
419}
420
421impl Default for ApiClientBuilder {
422    fn default() -> Self {
423        Self {
424            client: reqwest::Client::new(),
425            scheme: Scheme::HTTP,
426            host: IpAddr::V4(Ipv4Addr::LOCALHOST).to_string(),
427            port: 80,
428            base_path: None,
429            info: None,
430            servers: Vec::new(),
431            authentication: None,
432            security_schemes: IndexMap::new(),
433            default_security: Vec::new(),
434        }
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use http::uri::Scheme;
442    use utoipa::openapi::{InfoBuilder, ServerBuilder};
443
444    #[tokio::test]
445    async fn test_default_builder_creates_localhost_http_client() {
446        let client = ApiClientBuilder::default()
447            .build()
448            .expect("should build client");
449
450        let uri = client.base_uri.to_string();
451        insta::assert_snapshot!(uri, @"http://127.0.0.1:80/");
452    }
453
454    #[tokio::test]
455    async fn test_builder_with_custom_scheme() {
456        let client = ApiClientBuilder::default()
457            .with_scheme(Scheme::HTTPS)
458            .build()
459            .expect("should build client");
460
461        let uri = client.base_uri.to_string();
462        insta::assert_snapshot!(uri, @"https://127.0.0.1:80/");
463    }
464
465    #[tokio::test]
466    async fn test_builder_with_custom_host() {
467        let client = ApiClientBuilder::default()
468            .with_host("api.example.com")
469            .build()
470            .expect("should build client");
471
472        let uri = client.base_uri.to_string();
473        insta::assert_snapshot!(uri, @"http://api.example.com:80/");
474    }
475
476    #[tokio::test]
477    async fn test_builder_with_custom_port() {
478        let client = ApiClientBuilder::default()
479            .with_port(8080)
480            .build()
481            .expect("should build client");
482
483        let uri = client.base_uri.to_string();
484        insta::assert_snapshot!(uri, @"http://127.0.0.1:8080/");
485    }
486
487    #[tokio::test]
488    async fn test_builder_with_valid_base_path() {
489        let client = ApiClientBuilder::default()
490            .with_base_path("/api/v1")
491            .expect("valid base path")
492            .build()
493            .expect("should build client");
494
495        insta::assert_debug_snapshot!(client.base_path, @r#""/api/v1""#);
496    }
497
498    #[test]
499    fn test_builder_with_invalid_base_path_warns_and_continues() {
500        let result = ApiClientBuilder::default().with_base_path("invalid path with spaces");
501        assert!(result.is_err());
502    }
503
504    #[tokio::test]
505    async fn test_builder_with_info() {
506        let info = InfoBuilder::new()
507            .title("Test API")
508            .version("1.0.0")
509            .description(Some("Test API description"))
510            .build();
511
512        let client = ApiClientBuilder::default()
513            .with_info(info.clone())
514            .build()
515            .expect("should build client");
516
517        assert_eq!(client.info, Some(info));
518    }
519
520    #[tokio::test]
521    async fn test_builder_with_servers() {
522        let servers = vec![
523            ServerBuilder::new()
524                .url("https://api.example.com")
525                .description(Some("Production server"))
526                .build(),
527            ServerBuilder::new()
528                .url("https://staging.example.com")
529                .description(Some("Staging server"))
530                .build(),
531        ];
532
533        let client = ApiClientBuilder::default()
534            .with_servers(servers.clone())
535            .build()
536            .expect("should build client");
537
538        assert_eq!(client.servers, servers);
539    }
540
541    #[tokio::test]
542    async fn test_builder_add_server() {
543        let server1 = ServerBuilder::new()
544            .url("https://api.example.com")
545            .description(Some("Production server"))
546            .build();
547
548        let server2 = ServerBuilder::new()
549            .url("https://staging.example.com")
550            .description(Some("Staging server"))
551            .build();
552
553        let client = ApiClientBuilder::default()
554            .add_server(server1.clone())
555            .add_server(server2.clone())
556            .build()
557            .expect("should build client");
558
559        assert_eq!(client.servers, vec![server1, server2]);
560    }
561
562    #[tokio::test]
563    async fn test_builder_with_complete_openapi_config() {
564        let info = InfoBuilder::new()
565            .title("Complete API")
566            .version("2.0.0")
567            .description(Some("A fully configured API"))
568            .build();
569
570        let server = ServerBuilder::new()
571            .url("https://api.example.com/v2")
572            .description(Some("Production server"))
573            .build();
574
575        let client = ApiClientBuilder::default()
576            .with_scheme(Scheme::HTTPS)
577            .with_host("api.example.com")
578            .with_port(443)
579            .with_base_path("/v2")
580            .expect("valid base path")
581            .with_info(info.clone())
582            .add_server(server.clone())
583            .build()
584            .expect("should build client");
585
586        assert_eq!(client.info, Some(info));
587        assert_eq!(client.servers, vec![server]);
588        insta::assert_debug_snapshot!(client.base_path, @r#""/v2""#);
589        assert_eq!(
590            client.base_uri.to_string(),
591            "https://api.example.com:443/v2"
592        );
593    }
594
595    #[tokio::test]
596    async fn test_builder_with_authentication_bearer() {
597        let client = ApiClientBuilder::default()
598            .with_authentication(super::super::Authentication::Bearer("test-token".into()))
599            .build()
600            .expect("should build client");
601
602        assert!(matches!(
603            client.authentication,
604            Some(super::super::Authentication::Bearer(ref token)) if token.equals_str("test-token")
605        ));
606    }
607
608    #[tokio::test]
609    async fn test_builder_with_authentication_basic() {
610        let client = ApiClientBuilder::default()
611            .with_authentication(super::super::Authentication::Basic {
612                username: "user".to_string(),
613                password: "pass".into(),
614            })
615            .build()
616            .expect("should build client");
617
618        assert!(matches!(
619            client.authentication,
620            Some(super::super::Authentication::Basic { ref username, ref password })
621                if username == "user" && password.equals_str("pass")
622        ));
623    }
624
625    #[tokio::test]
626    async fn test_builder_with_authentication_api_key() {
627        let client = ApiClientBuilder::default()
628            .with_authentication(super::super::Authentication::ApiKey {
629                header_name: "X-API-Key".to_string(),
630                key: "secret-key".into(),
631            })
632            .build()
633            .expect("should build client");
634
635        assert!(matches!(
636            client.authentication,
637            Some(super::super::Authentication::ApiKey { ref header_name, ref key })
638                if header_name == "X-API-Key" && key.equals_str("secret-key")
639        ));
640    }
641
642    #[tokio::test]
643    async fn test_builder_without_authentication() {
644        let client = ApiClientBuilder::default()
645            .build()
646            .expect("should build client");
647
648        assert!(client.authentication.is_none());
649    }
650
651    #[tokio::test]
652    async fn test_builder_with_security_scheme() {
653        use super::super::security::{ApiKeyLocation, SecurityScheme};
654
655        let client = ApiClientBuilder::default()
656            .with_security_scheme("bearerAuth", SecurityScheme::bearer())
657            .with_security_scheme(
658                "apiKey",
659                SecurityScheme::api_key("X-API-Key", ApiKeyLocation::Header),
660            )
661            .build()
662            .expect("should build client");
663
664        assert_eq!(client.security_schemes.len(), 2);
665        assert!(client.security_schemes.contains_key("bearerAuth"));
666        assert!(client.security_schemes.contains_key("apiKey"));
667    }
668
669    #[tokio::test]
670    async fn test_builder_with_default_security() {
671        use super::super::security::{SecurityRequirement, SecurityScheme};
672
673        let client = ApiClientBuilder::default()
674            .with_security_scheme("bearerAuth", SecurityScheme::bearer())
675            .with_default_security(SecurityRequirement::new("bearerAuth"))
676            .build()
677            .expect("should build client");
678
679        assert_eq!(client.default_security.len(), 1);
680        assert_eq!(client.default_security[0].name, "bearerAuth");
681    }
682
683    #[tokio::test]
684    async fn test_builder_with_multiple_default_securities() {
685        use super::super::security::{ApiKeyLocation, SecurityRequirement, SecurityScheme};
686
687        let client = ApiClientBuilder::default()
688            .with_security_scheme("bearerAuth", SecurityScheme::bearer())
689            .with_security_scheme(
690                "apiKey",
691                SecurityScheme::api_key("X-API-Key", ApiKeyLocation::Header),
692            )
693            .with_default_securities([
694                SecurityRequirement::new("bearerAuth"),
695                SecurityRequirement::new("apiKey"),
696            ])
697            .build()
698            .expect("should build client");
699
700        assert_eq!(client.default_security.len(), 2);
701    }
702
703    #[tokio::test]
704    async fn test_builder_security_scheme_with_description() {
705        use super::super::security::SecurityScheme;
706
707        let client = ApiClientBuilder::default()
708            .with_security_scheme(
709                "bearerAuth",
710                SecurityScheme::bearer_with_format("JWT")
711                    .with_description("JWT token from /auth/login"),
712            )
713            .build()
714            .expect("should build client");
715
716        let scheme = client.security_schemes.get("bearerAuth").unwrap();
717        assert!(matches!(
718            scheme,
719            SecurityScheme::Bearer {
720                format: Some(f),
721                description: Some(d)
722            } if f == "JWT" && d == "JWT token from /auth/login"
723        ));
724    }
725
726    #[tokio::test]
727    async fn test_security_schemes_appear_in_openapi() {
728        use super::super::security::{SecurityRequirement, SecurityScheme};
729
730        let mut client = ApiClientBuilder::default()
731            .with_security_scheme("bearerAuth", SecurityScheme::bearer_with_format("JWT"))
732            .with_default_security(SecurityRequirement::new("bearerAuth"))
733            .build()
734            .expect("should build client");
735
736        let openapi = client.collected_openapi().await;
737
738        // Check that security schemes are in components
739        let components = openapi.components.expect("should have components");
740        let security_schemes = components.security_schemes;
741        assert!(security_schemes.contains_key("bearerAuth"));
742
743        // Check that default security is present
744        let security = openapi.security.expect("should have security");
745        assert!(!security.is_empty());
746    }
747}