clawspec_core/client/
builder.rs

1use std::fmt::Debug;
2use std::net::{IpAddr, Ipv4Addr};
3
4use http::Uri;
5use http::uri::{PathAndQuery, Scheme};
6use utoipa::openapi::{Info, Server};
7
8use super::{ApiClient, ApiClientError};
9
10/// Builder for creating `ApiClient` instances with comprehensive configuration options.
11///
12/// `ApiClientBuilder` provides a fluent interface for configuring all aspects of an API client,
13/// including network settings, base paths, OpenAPI metadata, and server definitions.
14///
15/// # Default Configuration
16///
17/// - **Scheme**: HTTP (use `with_scheme()` to change to HTTPS)
18/// - **Host**: 127.0.0.1 (localhost)
19/// - **Port**: 80 (standard HTTP port)
20/// - **Base path**: None (requests go to root path)
21/// - **OpenAPI info**: None (no metadata)
22/// - **Servers**: Empty list
23///
24/// # Example
25///
26/// ```rust
27/// use clawspec_core::ApiClient;
28/// use http::uri::Scheme;
29/// use utoipa::openapi::{InfoBuilder, ServerBuilder};
30///
31/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
32/// let client = ApiClient::builder()
33///     .with_scheme(Scheme::HTTPS)
34///     .with_host("api.example.com")
35///     .with_port(443)
36///     .with_base_path("/v1")?
37///     .with_info(
38///         InfoBuilder::new()
39///             .title("Example API")
40///             .version("1.0.0")
41///             .description(Some("API documentation generated from tests"))
42///             .build()
43///     )
44///     .add_server(
45///         ServerBuilder::new()
46///             .url("https://api.example.com/v1")
47///             .description(Some("Production server"))
48///             .build()
49///     )
50///     .add_server(
51///         ServerBuilder::new()
52///             .url("https://staging.example.com/v1")
53///             .description(Some("Staging server"))
54///             .build()
55///     )
56///     .build()?;
57/// # Ok(())
58/// # }
59/// ```
60#[derive(Debug, Clone)]
61pub struct ApiClientBuilder {
62    client: reqwest::Client,
63    scheme: Scheme,
64    host: String,
65    port: u16,
66    base_path: Option<PathAndQuery>,
67    info: Option<Info>,
68    servers: Vec<Server>,
69    authentication: Option<super::Authentication>,
70}
71
72impl ApiClientBuilder {
73    /// Builds the final `ApiClient` instance with all configured settings.
74    ///
75    /// This method consumes the builder and creates an `ApiClient` ready for making API calls.
76    /// All configuration options set through the builder methods are applied to the client.
77    ///
78    /// # Returns
79    ///
80    /// Returns a `Result<ApiClient, ApiClientError>` which will be:
81    /// - `Ok(ApiClient)` if the client was created successfully
82    /// - `Err(ApiClientError)` if there was an error building the URI or other configuration issues
83    ///
84    /// # Errors
85    ///
86    /// This method can fail if:
87    /// - The base URI cannot be constructed from the provided scheme, host, and port
88    /// - The base path is invalid and cannot be parsed
89    ///
90    /// # Example
91    ///
92    /// ```rust
93    /// use clawspec_core::ApiClient;
94    ///
95    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
96    /// let client = ApiClient::builder()
97    ///     .with_host("api.example.com")
98    ///     .with_base_path("/v1")?
99    ///     .build()?;  // This consumes the builder
100    ///
101    /// // Now you can use the client for API calls
102    /// # Ok(())
103    /// # }
104    /// ```
105    pub fn build(self) -> Result<ApiClient, ApiClientError> {
106        let Self {
107            client,
108            scheme,
109            host,
110            port,
111            base_path,
112            info,
113            servers,
114            authentication,
115        } = self;
116
117        let builder = Uri::builder()
118            .scheme(scheme)
119            .authority(format!("{host}:{port}"));
120        let builder = if let Some(path) = &base_path {
121            builder.path_and_query(path.path())
122        } else {
123            builder.path_and_query("/")
124        };
125
126        let base_uri = builder.build()?;
127        let base_path = base_path
128            .as_ref()
129            .map(|it| it.path().to_string())
130            .unwrap_or_default();
131
132        let collectors = Default::default();
133
134        Ok(ApiClient {
135            client,
136            base_uri,
137            base_path,
138            info,
139            servers,
140            collectors,
141            authentication,
142        })
143    }
144
145    /// Sets the HTTP scheme (protocol) for the API client.
146    ///
147    /// # Parameters
148    ///
149    /// * `scheme` - The HTTP scheme to use (HTTP or HTTPS)
150    ///
151    /// # Default
152    ///
153    /// If not specified, defaults to `Scheme::HTTP`.
154    ///
155    /// # Example
156    ///
157    /// ```rust
158    /// use clawspec_core::ApiClient;
159    /// use http::uri::Scheme;
160    ///
161    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
162    /// let client = ApiClient::builder()
163    ///     .with_scheme(Scheme::HTTPS)  // Use HTTPS for secure connections
164    ///     .with_host("api.example.com")
165    ///     .build()?;
166    /// # Ok(())
167    /// # }
168    /// ```
169    pub fn with_scheme(mut self, scheme: Scheme) -> Self {
170        self.scheme = scheme;
171        self
172    }
173
174    /// Sets the hostname for the API client.
175    ///
176    /// # Parameters
177    ///
178    /// * `host` - The hostname or IP address of the API server
179    ///
180    /// # Default
181    ///
182    /// If not specified, defaults to `"127.0.0.1"` (localhost).
183    ///
184    /// # Example
185    ///
186    /// ```rust
187    /// use clawspec_core::ApiClient;
188    ///
189    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
190    /// let client = ApiClient::builder()
191    ///     .with_host("api.example.com")     // Domain name
192    ///     .build()?;
193    ///
194    /// let client = ApiClient::builder()
195    ///     .with_host("192.168.1.10")       // IP address
196    ///     .build()?;
197    ///
198    /// let client = ApiClient::builder()
199    ///     .with_host("localhost")          // Local development
200    ///     .build()?;
201    /// # Ok(())
202    /// # }
203    /// ```
204    pub fn with_host(mut self, host: impl Into<String>) -> Self {
205        self.host = host.into();
206        self
207    }
208
209    /// Sets the port number for the API client.
210    ///
211    /// # Parameters
212    ///
213    /// * `port` - The port number to connect to on the server
214    ///
215    /// # Default
216    ///
217    /// If not specified, defaults to `80` (standard HTTP port).
218    ///
219    /// # Example
220    ///
221    /// ```rust
222    /// use clawspec_core::ApiClient;
223    /// use http::uri::Scheme;
224    ///
225    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
226    /// let client = ApiClient::builder()
227    ///     .with_scheme(Scheme::HTTPS)
228    ///     .with_host("api.example.com")
229    ///     .with_port(443)              // Standard HTTPS port
230    ///     .build()?;
231    ///
232    /// let client = ApiClient::builder()
233    ///     .with_host("localhost")
234    ///     .with_port(8080)             // Common development port
235    ///     .build()?;
236    /// # Ok(())
237    /// # }
238    /// ```
239    pub fn with_port(mut self, port: u16) -> Self {
240        self.port = port;
241        self
242    }
243
244    /// Sets the base path for all API requests.
245    ///
246    /// This path will be prepended to all request paths. The path must be valid
247    /// according to URI standards (no spaces, properly encoded, etc.).
248    ///
249    /// # Examples
250    ///
251    /// ```rust
252    /// use clawspec_core::ApiClient;
253    ///
254    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
255    /// // API versioning
256    /// let client = ApiClient::builder()
257    ///     .with_host("api.example.com")
258    ///     .with_base_path("/v1")?              // All requests will start with /v1
259    ///     .build()?;
260    ///
261    /// // More complex base paths
262    /// let client = ApiClient::builder()
263    ///     .with_base_path("/api/v2")?          // Multiple path segments
264    ///     .build()?;
265    ///
266    /// // Nested API paths
267    /// let client = ApiClient::builder()
268    ///     .with_base_path("/services/user-api/v1")?  // Deep nesting
269    ///     .build()?;
270    /// # Ok(())
271    /// # }
272    /// ```
273    ///
274    /// # Errors
275    ///
276    /// Returns `ApiClientError::InvalidBasePath` if the path contains invalid characters
277    /// (such as spaces) or cannot be parsed as a valid URI path.
278    pub fn with_base_path<P>(mut self, base_path: P) -> Result<Self, ApiClientError>
279    where
280        P: TryInto<PathAndQuery>,
281        P::Error: Debug + 'static,
282    {
283        let base_path = base_path
284            .try_into()
285            .map_err(|err| ApiClientError::InvalidBasePath {
286                error: format!("{err:?}"),
287            })?;
288        self.base_path = Some(base_path);
289        Ok(self)
290    }
291
292    /// Sets the OpenAPI info metadata for the generated specification.
293    ///
294    /// The info object provides metadata about the API including title, version,
295    /// description, contact information, license, and other details that will
296    /// appear in the generated OpenAPI specification.
297    ///
298    /// # Parameters
299    ///
300    /// * `info` - The OpenAPI Info object containing API metadata
301    ///
302    /// # Example
303    ///
304    /// ```rust
305    /// use clawspec_core::ApiClient;
306    /// use utoipa::openapi::InfoBuilder;
307    ///
308    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
309    /// let client = ApiClient::builder()
310    ///     .with_info(
311    ///         InfoBuilder::new()
312    ///             .title("My API")
313    ///             .version("1.0.0")
314    ///             .description(Some("A comprehensive API for managing resources"))
315    ///             .build()
316    ///     )
317    ///     .build()?;
318    /// # Ok(())
319    /// # }
320    /// ```
321    ///
322    /// # Notes
323    ///
324    /// - If no info is set, the generated OpenAPI specification will not include an info section
325    /// - The info can be updated by calling this method multiple times (last call wins)
326    /// - Common practice is to set at least title and version for OpenAPI compliance
327    pub fn with_info(mut self, info: Info) -> Self {
328        self.info = Some(info);
329        self
330    }
331
332    /// Sets the complete list of servers for the OpenAPI specification.
333    ///
334    /// This method replaces any previously configured servers. Use `add_server()`
335    /// if you want to add servers incrementally.
336    ///
337    /// # Parameters
338    ///
339    /// * `servers` - A vector of Server objects defining the available API servers
340    ///
341    /// # Example
342    ///
343    /// ```rust
344    /// use clawspec_core::ApiClient;
345    /// use utoipa::openapi::ServerBuilder;
346    ///
347    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
348    /// let servers = vec![
349    ///     ServerBuilder::new()
350    ///         .url("https://api.example.com/v1")
351    ///         .description(Some("Production server"))
352    ///         .build(),
353    ///     ServerBuilder::new()
354    ///         .url("https://staging.example.com/v1")
355    ///         .description(Some("Staging server"))
356    ///         .build(),
357    /// ];
358    ///
359    /// let client = ApiClient::builder()
360    ///     .with_servers(servers)
361    ///     .build()?;
362    /// # Ok(())
363    /// # }
364    /// ```
365    pub fn with_servers(mut self, servers: Vec<Server>) -> Self {
366        self.servers = servers;
367        self
368    }
369
370    /// Adds a single server to the OpenAPI specification.
371    ///
372    /// This method allows you to incrementally add servers to the configuration.
373    /// Each call adds to the existing list of servers.
374    ///
375    /// # Parameters
376    ///
377    /// * `server` - A Server object defining an available API server
378    ///
379    /// # Example
380    ///
381    /// ```rust
382    /// use clawspec_core::ApiClient;
383    /// use utoipa::openapi::ServerBuilder;
384    ///
385    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
386    /// let client = ApiClient::builder()
387    ///     .add_server(
388    ///         ServerBuilder::new()
389    ///             .url("https://api.example.com/v1")
390    ///             .description(Some("Production server"))
391    ///             .build()
392    ///     )
393    ///     .add_server(
394    ///         ServerBuilder::new()
395    ///             .url("https://staging.example.com/v1")
396    ///             .description(Some("Staging server"))
397    ///             .build()
398    ///     )
399    ///     .add_server(
400    ///         ServerBuilder::new()
401    ///             .url("http://localhost:8080")
402    ///             .description(Some("Development server"))
403    ///             .build()
404    ///     )
405    ///     .build()?;
406    /// # Ok(())
407    /// # }
408    /// ```
409    ///
410    /// # Server Definition Best Practices
411    ///
412    /// - Include meaningful descriptions for each server
413    /// - Order servers by preference (production first, then staging, then development)
414    /// - Use HTTPS for production servers when available
415    /// - Include the full base URL including API version paths
416    pub fn add_server(mut self, server: Server) -> Self {
417        self.servers.push(server);
418        self
419    }
420
421    /// Sets the authentication configuration for the API client.
422    ///
423    /// This authentication will be applied to all requests made by the client,
424    /// unless overridden on a per-request basis.
425    ///
426    /// # Examples
427    ///
428    /// ```rust
429    /// use clawspec_core::{ApiClient, Authentication};
430    ///
431    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
432    /// // Bearer token authentication
433    /// let client = ApiClient::builder()
434    ///     .with_authentication(Authentication::Bearer("my-api-token".into()))
435    ///     .build()?;
436    ///
437    /// // Basic authentication
438    /// let client = ApiClient::builder()
439    ///     .with_authentication(Authentication::Basic {
440    ///         username: "user".to_string(),
441    ///         password: "pass".into(),
442    ///     })
443    ///     .build()?;
444    ///
445    /// // API key authentication
446    /// let client = ApiClient::builder()
447    ///     .with_authentication(Authentication::ApiKey {
448    ///         header_name: "X-API-Key".to_string(),
449    ///         key: "secret-key".into(),
450    ///     })
451    ///     .build()?;
452    /// # Ok(())
453    /// # }
454    /// ```
455    ///
456    /// # Authentication Types
457    ///
458    /// - **Bearer**: Adds `Authorization: Bearer <token>` header
459    /// - **Basic**: Adds `Authorization: Basic <base64(username:password)>` header
460    /// - **ApiKey**: Adds custom header with API key
461    ///
462    /// # Security Considerations
463    ///
464    /// - Authentication credentials are stored in memory and may be logged
465    /// - Use secure token storage and rotation practices
466    /// - Avoid hardcoding credentials in source code
467    /// - Consider using environment variables or secure vaults
468    pub fn with_authentication(mut self, authentication: super::Authentication) -> Self {
469        self.authentication = Some(authentication);
470        self
471    }
472}
473
474impl Default for ApiClientBuilder {
475    fn default() -> Self {
476        Self {
477            client: reqwest::Client::new(),
478            scheme: Scheme::HTTP,
479            host: IpAddr::V4(Ipv4Addr::LOCALHOST).to_string(),
480            port: 80,
481            base_path: None,
482            info: None,
483            servers: Vec::new(),
484            authentication: None,
485        }
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use http::uri::Scheme;
493    use utoipa::openapi::{InfoBuilder, ServerBuilder};
494
495    #[test]
496    fn test_default_builder_creates_localhost_http_client() {
497        let client = ApiClientBuilder::default()
498            .build()
499            .expect("should build client");
500
501        let uri = client.base_uri.to_string();
502        insta::assert_snapshot!(uri, @"http://127.0.0.1:80/");
503    }
504
505    #[test]
506    fn test_builder_with_custom_scheme() {
507        let client = ApiClientBuilder::default()
508            .with_scheme(Scheme::HTTPS)
509            .build()
510            .expect("should build client");
511
512        let uri = client.base_uri.to_string();
513        insta::assert_snapshot!(uri, @"https://127.0.0.1:80/");
514    }
515
516    #[test]
517    fn test_builder_with_custom_host() {
518        let client = ApiClientBuilder::default()
519            .with_host("api.example.com")
520            .build()
521            .expect("should build client");
522
523        let uri = client.base_uri.to_string();
524        insta::assert_snapshot!(uri, @"http://api.example.com:80/");
525    }
526
527    #[test]
528    fn test_builder_with_custom_port() {
529        let client = ApiClientBuilder::default()
530            .with_port(8080)
531            .build()
532            .expect("should build client");
533
534        let uri = client.base_uri.to_string();
535        insta::assert_snapshot!(uri, @"http://127.0.0.1:8080/");
536    }
537
538    #[test]
539    fn test_builder_with_valid_base_path() {
540        let client = ApiClientBuilder::default()
541            .with_base_path("/api/v1")
542            .expect("valid base path")
543            .build()
544            .expect("should build client");
545
546        insta::assert_debug_snapshot!(client.base_path, @r#""/api/v1""#);
547    }
548
549    #[test]
550    fn test_builder_with_invalid_base_path_warns_and_continues() {
551        let result = ApiClientBuilder::default().with_base_path("invalid path with spaces");
552        assert!(result.is_err());
553    }
554
555    #[test]
556    fn test_builder_with_info() {
557        let info = InfoBuilder::new()
558            .title("Test API")
559            .version("1.0.0")
560            .description(Some("Test API description"))
561            .build();
562
563        let client = ApiClientBuilder::default()
564            .with_info(info.clone())
565            .build()
566            .expect("should build client");
567
568        assert_eq!(client.info, Some(info));
569    }
570
571    #[test]
572    fn test_builder_with_servers() {
573        let servers = vec![
574            ServerBuilder::new()
575                .url("https://api.example.com")
576                .description(Some("Production server"))
577                .build(),
578            ServerBuilder::new()
579                .url("https://staging.example.com")
580                .description(Some("Staging server"))
581                .build(),
582        ];
583
584        let client = ApiClientBuilder::default()
585            .with_servers(servers.clone())
586            .build()
587            .expect("should build client");
588
589        assert_eq!(client.servers, servers);
590    }
591
592    #[test]
593    fn test_builder_add_server() {
594        let server1 = ServerBuilder::new()
595            .url("https://api.example.com")
596            .description(Some("Production server"))
597            .build();
598
599        let server2 = ServerBuilder::new()
600            .url("https://staging.example.com")
601            .description(Some("Staging server"))
602            .build();
603
604        let client = ApiClientBuilder::default()
605            .add_server(server1.clone())
606            .add_server(server2.clone())
607            .build()
608            .expect("should build client");
609
610        assert_eq!(client.servers, vec![server1, server2]);
611    }
612
613    #[test]
614    fn test_builder_with_complete_openapi_config() {
615        let info = InfoBuilder::new()
616            .title("Complete API")
617            .version("2.0.0")
618            .description(Some("A fully configured API"))
619            .build();
620
621        let server = ServerBuilder::new()
622            .url("https://api.example.com/v2")
623            .description(Some("Production server"))
624            .build();
625
626        let client = ApiClientBuilder::default()
627            .with_scheme(Scheme::HTTPS)
628            .with_host("api.example.com")
629            .with_port(443)
630            .with_base_path("/v2")
631            .expect("valid base path")
632            .with_info(info.clone())
633            .add_server(server.clone())
634            .build()
635            .expect("should build client");
636
637        assert_eq!(client.info, Some(info));
638        assert_eq!(client.servers, vec![server]);
639        insta::assert_debug_snapshot!(client.base_path, @r#""/v2""#);
640        assert_eq!(
641            client.base_uri.to_string(),
642            "https://api.example.com:443/v2"
643        );
644    }
645
646    #[test]
647    fn test_builder_with_authentication_bearer() {
648        let client = ApiClientBuilder::default()
649            .with_authentication(super::super::Authentication::Bearer("test-token".into()))
650            .build()
651            .expect("should build client");
652
653        assert!(matches!(
654            client.authentication,
655            Some(super::super::Authentication::Bearer(ref token)) if token.equals_str("test-token")
656        ));
657    }
658
659    #[test]
660    fn test_builder_with_authentication_basic() {
661        let client = ApiClientBuilder::default()
662            .with_authentication(super::super::Authentication::Basic {
663                username: "user".to_string(),
664                password: "pass".into(),
665            })
666            .build()
667            .expect("should build client");
668
669        assert!(matches!(
670            client.authentication,
671            Some(super::super::Authentication::Basic { ref username, ref password })
672                if username == "user" && password.equals_str("pass")
673        ));
674    }
675
676    #[test]
677    fn test_builder_with_authentication_api_key() {
678        let client = ApiClientBuilder::default()
679            .with_authentication(super::super::Authentication::ApiKey {
680                header_name: "X-API-Key".to_string(),
681                key: "secret-key".into(),
682            })
683            .build()
684            .expect("should build client");
685
686        assert!(matches!(
687            client.authentication,
688            Some(super::super::Authentication::ApiKey { ref header_name, ref key })
689                if header_name == "X-API-Key" && key.equals_str("secret-key")
690        ));
691    }
692
693    #[test]
694    fn test_builder_without_authentication() {
695        let client = ApiClientBuilder::default()
696            .build()
697            .expect("should build client");
698
699        assert!(client.authentication.is_none());
700    }
701}