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#[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 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 pub fn with_scheme(mut self, scheme: Scheme) -> Self {
156 self.scheme = scheme;
157 self
158 }
159
160 pub fn with_host(mut self, host: impl Into<String>) -> Self {
162 self.host = host.into();
163 self
164 }
165
166 pub fn with_port(mut self, port: u16) -> Self {
168 self.port = port;
169 self
170 }
171
172 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 pub fn with_info(mut self, info: Info) -> Self {
195 self.info = Some(info);
196 self
197 }
198
199 pub fn with_servers(mut self, servers: Vec<Server>) -> Self {
201 self.servers = servers;
202 self
203 }
204
205 pub fn add_server(mut self, server: Server) -> Self {
207 self.servers.push(server);
208 self
209 }
210
211 pub fn with_authentication(mut self, authentication: super::Authentication) -> Self {
215 self.authentication = Some(authentication);
216 self
217 }
218
219 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 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 pub fn with_https(mut self) -> Self {
253 self.scheme = Scheme::HTTPS;
254 self
255 }
256
257 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 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 pub fn with_default_security(mut self, requirement: SecurityRequirement) -> Self {
280 self.default_security.push(requirement);
281 self
282 }
283
284 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 #[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 #[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 #[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 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 let components = openapi.components.expect("should have components");
740 let security_schemes = components.security_schemes;
741 assert!(security_schemes.contains_key("bearerAuth"));
742
743 let security = openapi.security.expect("should have security");
745 assert!(!security.is_empty());
746 }
747}