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}