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}