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}
70
71impl ApiClientBuilder {
72    /// Builds the final `ApiClient` instance with all configured settings.
73    ///
74    /// This method consumes the builder and creates an `ApiClient` ready for making API calls.
75    /// All configuration options set through the builder methods are applied to the client.
76    ///
77    /// # Returns
78    ///
79    /// Returns a `Result<ApiClient, ApiClientError>` which will be:
80    /// - `Ok(ApiClient)` if the client was created successfully
81    /// - `Err(ApiClientError)` if there was an error building the URI or other configuration issues
82    ///
83    /// # Errors
84    ///
85    /// This method can fail if:
86    /// - The base URI cannot be constructed from the provided scheme, host, and port
87    /// - The base path is invalid and cannot be parsed
88    ///
89    /// # Example
90    ///
91    /// ```rust
92    /// use clawspec_core::ApiClient;
93    ///
94    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
95    /// let client = ApiClient::builder()
96    ///     .with_host("api.example.com")
97    ///     .with_base_path("/v1")?
98    ///     .build()?;  // This consumes the builder
99    ///
100    /// // Now you can use the client for API calls
101    /// # Ok(())
102    /// # }
103    /// ```
104    pub fn build(self) -> Result<ApiClient, ApiClientError> {
105        let Self {
106            client,
107            scheme,
108            host,
109            port,
110            base_path,
111            info,
112            servers,
113        } = self;
114
115        let builder = Uri::builder()
116            .scheme(scheme)
117            .authority(format!("{host}:{port}"));
118        let builder = if let Some(path) = &base_path {
119            builder.path_and_query(path.path())
120        } else {
121            builder.path_and_query("/")
122        };
123
124        let base_uri = builder.build()?;
125        let base_path = base_path
126            .as_ref()
127            .map(|it| it.path().to_string())
128            .unwrap_or_default();
129
130        let collectors = Default::default();
131
132        Ok(ApiClient {
133            client,
134            base_uri,
135            base_path,
136            info,
137            servers,
138            collectors,
139        })
140    }
141
142    /// Sets the HTTP scheme (protocol) for the API client.
143    ///
144    /// # Parameters
145    ///
146    /// * `scheme` - The HTTP scheme to use (HTTP or HTTPS)
147    ///
148    /// # Default
149    ///
150    /// If not specified, defaults to `Scheme::HTTP`.
151    ///
152    /// # Example
153    ///
154    /// ```rust
155    /// use clawspec_core::ApiClient;
156    /// use http::uri::Scheme;
157    ///
158    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
159    /// let client = ApiClient::builder()
160    ///     .with_scheme(Scheme::HTTPS)  // Use HTTPS for secure connections
161    ///     .with_host("api.example.com")
162    ///     .build()?;
163    /// # Ok(())
164    /// # }
165    /// ```
166    pub fn with_scheme(mut self, scheme: Scheme) -> Self {
167        self.scheme = scheme;
168        self
169    }
170
171    /// Sets the hostname for the API client.
172    ///
173    /// # Parameters
174    ///
175    /// * `host` - The hostname or IP address of the API server
176    ///
177    /// # Default
178    ///
179    /// If not specified, defaults to `"127.0.0.1"` (localhost).
180    ///
181    /// # Example
182    ///
183    /// ```rust
184    /// use clawspec_core::ApiClient;
185    ///
186    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
187    /// let client = ApiClient::builder()
188    ///     .with_host("api.example.com")     // Domain name
189    ///     .build()?;
190    ///
191    /// let client = ApiClient::builder()
192    ///     .with_host("192.168.1.10")       // IP address
193    ///     .build()?;
194    ///
195    /// let client = ApiClient::builder()
196    ///     .with_host("localhost")          // Local development
197    ///     .build()?;
198    /// # Ok(())
199    /// # }
200    /// ```
201    pub fn with_host(mut self, host: impl Into<String>) -> Self {
202        self.host = host.into();
203        self
204    }
205
206    /// Sets the port number for the API client.
207    ///
208    /// # Parameters
209    ///
210    /// * `port` - The port number to connect to on the server
211    ///
212    /// # Default
213    ///
214    /// If not specified, defaults to `80` (standard HTTP port).
215    ///
216    /// # Example
217    ///
218    /// ```rust
219    /// use clawspec_core::ApiClient;
220    /// use http::uri::Scheme;
221    ///
222    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
223    /// let client = ApiClient::builder()
224    ///     .with_scheme(Scheme::HTTPS)
225    ///     .with_host("api.example.com")
226    ///     .with_port(443)              // Standard HTTPS port
227    ///     .build()?;
228    ///
229    /// let client = ApiClient::builder()
230    ///     .with_host("localhost")
231    ///     .with_port(8080)             // Common development port
232    ///     .build()?;
233    /// # Ok(())
234    /// # }
235    /// ```
236    pub fn with_port(mut self, port: u16) -> Self {
237        self.port = port;
238        self
239    }
240
241    /// Sets the base path for all API requests.
242    ///
243    /// This path will be prepended to all request paths. The path must be valid
244    /// according to URI standards (no spaces, properly encoded, etc.).
245    ///
246    /// # Examples
247    ///
248    /// ```rust
249    /// use clawspec_core::ApiClient;
250    ///
251    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
252    /// // API versioning
253    /// let client = ApiClient::builder()
254    ///     .with_host("api.example.com")
255    ///     .with_base_path("/v1")?              // All requests will start with /v1
256    ///     .build()?;
257    ///
258    /// // More complex base paths
259    /// let client = ApiClient::builder()
260    ///     .with_base_path("/api/v2")?          // Multiple path segments
261    ///     .build()?;
262    ///
263    /// // Nested API paths
264    /// let client = ApiClient::builder()
265    ///     .with_base_path("/services/user-api/v1")?  // Deep nesting
266    ///     .build()?;
267    /// # Ok(())
268    /// # }
269    /// ```
270    ///
271    /// # Errors
272    ///
273    /// Returns `ApiClientError::InvalidBasePath` if the path contains invalid characters
274    /// (such as spaces) or cannot be parsed as a valid URI path.
275    pub fn with_base_path<P>(mut self, base_path: P) -> Result<Self, ApiClientError>
276    where
277        P: TryInto<PathAndQuery>,
278        P::Error: Debug + 'static,
279    {
280        let base_path = base_path
281            .try_into()
282            .map_err(|err| ApiClientError::InvalidBasePath {
283                error: format!("{err:?}"),
284            })?;
285        self.base_path = Some(base_path);
286        Ok(self)
287    }
288
289    /// Sets the OpenAPI info metadata for the generated specification.
290    ///
291    /// The info object provides metadata about the API including title, version,
292    /// description, contact information, license, and other details that will
293    /// appear in the generated OpenAPI specification.
294    ///
295    /// # Parameters
296    ///
297    /// * `info` - The OpenAPI Info object containing API metadata
298    ///
299    /// # Example
300    ///
301    /// ```rust
302    /// use clawspec_core::ApiClient;
303    /// use utoipa::openapi::InfoBuilder;
304    ///
305    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
306    /// let client = ApiClient::builder()
307    ///     .with_info(
308    ///         InfoBuilder::new()
309    ///             .title("My API")
310    ///             .version("1.0.0")
311    ///             .description(Some("A comprehensive API for managing resources"))
312    ///             .build()
313    ///     )
314    ///     .build()?;
315    /// # Ok(())
316    /// # }
317    /// ```
318    ///
319    /// # Notes
320    ///
321    /// - If no info is set, the generated OpenAPI specification will not include an info section
322    /// - The info can be updated by calling this method multiple times (last call wins)
323    /// - Common practice is to set at least title and version for OpenAPI compliance
324    pub fn with_info(mut self, info: Info) -> Self {
325        self.info = Some(info);
326        self
327    }
328
329    /// Sets the complete list of servers for the OpenAPI specification.
330    ///
331    /// This method replaces any previously configured servers. Use `add_server()`
332    /// if you want to add servers incrementally.
333    ///
334    /// # Parameters
335    ///
336    /// * `servers` - A vector of Server objects defining the available API servers
337    ///
338    /// # Example
339    ///
340    /// ```rust
341    /// use clawspec_core::ApiClient;
342    /// use utoipa::openapi::ServerBuilder;
343    ///
344    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
345    /// let servers = vec![
346    ///     ServerBuilder::new()
347    ///         .url("https://api.example.com/v1")
348    ///         .description(Some("Production server"))
349    ///         .build(),
350    ///     ServerBuilder::new()
351    ///         .url("https://staging.example.com/v1")
352    ///         .description(Some("Staging server"))
353    ///         .build(),
354    /// ];
355    ///
356    /// let client = ApiClient::builder()
357    ///     .with_servers(servers)
358    ///     .build()?;
359    /// # Ok(())
360    /// # }
361    /// ```
362    pub fn with_servers(mut self, servers: Vec<Server>) -> Self {
363        self.servers = servers;
364        self
365    }
366
367    /// Adds a single server to the OpenAPI specification.
368    ///
369    /// This method allows you to incrementally add servers to the configuration.
370    /// Each call adds to the existing list of servers.
371    ///
372    /// # Parameters
373    ///
374    /// * `server` - A Server object defining an available API server
375    ///
376    /// # Example
377    ///
378    /// ```rust
379    /// use clawspec_core::ApiClient;
380    /// use utoipa::openapi::ServerBuilder;
381    ///
382    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
383    /// let client = ApiClient::builder()
384    ///     .add_server(
385    ///         ServerBuilder::new()
386    ///             .url("https://api.example.com/v1")
387    ///             .description(Some("Production server"))
388    ///             .build()
389    ///     )
390    ///     .add_server(
391    ///         ServerBuilder::new()
392    ///             .url("https://staging.example.com/v1")
393    ///             .description(Some("Staging server"))
394    ///             .build()
395    ///     )
396    ///     .add_server(
397    ///         ServerBuilder::new()
398    ///             .url("http://localhost:8080")
399    ///             .description(Some("Development server"))
400    ///             .build()
401    ///     )
402    ///     .build()?;
403    /// # Ok(())
404    /// # }
405    /// ```
406    ///
407    /// # Server Definition Best Practices
408    ///
409    /// - Include meaningful descriptions for each server
410    /// - Order servers by preference (production first, then staging, then development)
411    /// - Use HTTPS for production servers when available
412    /// - Include the full base URL including API version paths
413    pub fn add_server(mut self, server: Server) -> Self {
414        self.servers.push(server);
415        self
416    }
417}
418
419impl Default for ApiClientBuilder {
420    fn default() -> Self {
421        Self {
422            client: reqwest::Client::new(),
423            scheme: Scheme::HTTP,
424            host: IpAddr::V4(Ipv4Addr::LOCALHOST).to_string(),
425            port: 80,
426            base_path: None,
427            info: None,
428            servers: Vec::new(),
429        }
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use http::uri::Scheme;
437    use utoipa::openapi::{InfoBuilder, ServerBuilder};
438
439    #[test]
440    fn test_default_builder_creates_localhost_http_client() {
441        let client = ApiClientBuilder::default()
442            .build()
443            .expect("should build client");
444
445        let uri = client.base_uri.to_string();
446        insta::assert_snapshot!(uri, @"http://127.0.0.1:80/");
447    }
448
449    #[test]
450    fn test_builder_with_custom_scheme() {
451        let client = ApiClientBuilder::default()
452            .with_scheme(Scheme::HTTPS)
453            .build()
454            .expect("should build client");
455
456        let uri = client.base_uri.to_string();
457        insta::assert_snapshot!(uri, @"https://127.0.0.1:80/");
458    }
459
460    #[test]
461    fn test_builder_with_custom_host() {
462        let client = ApiClientBuilder::default()
463            .with_host("api.example.com")
464            .build()
465            .expect("should build client");
466
467        let uri = client.base_uri.to_string();
468        insta::assert_snapshot!(uri, @"http://api.example.com:80/");
469    }
470
471    #[test]
472    fn test_builder_with_custom_port() {
473        let client = ApiClientBuilder::default()
474            .with_port(8080)
475            .build()
476            .expect("should build client");
477
478        let uri = client.base_uri.to_string();
479        insta::assert_snapshot!(uri, @"http://127.0.0.1:8080/");
480    }
481
482    #[test]
483    fn test_builder_with_valid_base_path() {
484        let client = ApiClientBuilder::default()
485            .with_base_path("/api/v1")
486            .expect("valid base path")
487            .build()
488            .expect("should build client");
489
490        insta::assert_debug_snapshot!(client.base_path, @r#""/api/v1""#);
491    }
492
493    #[test]
494    fn test_builder_with_invalid_base_path_warns_and_continues() {
495        let result = ApiClientBuilder::default().with_base_path("invalid path with spaces");
496        assert!(result.is_err());
497    }
498
499    #[test]
500    fn test_builder_with_info() {
501        let info = InfoBuilder::new()
502            .title("Test API")
503            .version("1.0.0")
504            .description(Some("Test API description"))
505            .build();
506
507        let client = ApiClientBuilder::default()
508            .with_info(info.clone())
509            .build()
510            .expect("should build client");
511
512        assert_eq!(client.info, Some(info));
513    }
514
515    #[test]
516    fn test_builder_with_servers() {
517        let servers = vec![
518            ServerBuilder::new()
519                .url("https://api.example.com")
520                .description(Some("Production server"))
521                .build(),
522            ServerBuilder::new()
523                .url("https://staging.example.com")
524                .description(Some("Staging server"))
525                .build(),
526        ];
527
528        let client = ApiClientBuilder::default()
529            .with_servers(servers.clone())
530            .build()
531            .expect("should build client");
532
533        assert_eq!(client.servers, servers);
534    }
535
536    #[test]
537    fn test_builder_add_server() {
538        let server1 = ServerBuilder::new()
539            .url("https://api.example.com")
540            .description(Some("Production server"))
541            .build();
542
543        let server2 = ServerBuilder::new()
544            .url("https://staging.example.com")
545            .description(Some("Staging server"))
546            .build();
547
548        let client = ApiClientBuilder::default()
549            .add_server(server1.clone())
550            .add_server(server2.clone())
551            .build()
552            .expect("should build client");
553
554        assert_eq!(client.servers, vec![server1, server2]);
555    }
556
557    #[test]
558    fn test_builder_with_complete_openapi_config() {
559        let info = InfoBuilder::new()
560            .title("Complete API")
561            .version("2.0.0")
562            .description(Some("A fully configured API"))
563            .build();
564
565        let server = ServerBuilder::new()
566            .url("https://api.example.com/v2")
567            .description(Some("Production server"))
568            .build();
569
570        let client = ApiClientBuilder::default()
571            .with_scheme(Scheme::HTTPS)
572            .with_host("api.example.com")
573            .with_port(443)
574            .with_base_path("/v2")
575            .expect("valid base path")
576            .with_info(info.clone())
577            .add_server(server.clone())
578            .build()
579            .expect("should build client");
580
581        assert_eq!(client.info, Some(info));
582        assert_eq!(client.servers, vec![server]);
583        insta::assert_debug_snapshot!(client.base_path, @r#""/v2""#);
584        assert_eq!(
585            client.base_uri.to_string(),
586            "https://api.example.com:443/v2"
587        );
588    }
589}