1use super::Auth;
7use crate::{Error, Result};
8use base64::Engine;
9use reqwest::{Client, Method, multipart};
10use semver::Version;
11use serde_json::Value;
12use std::collections::HashMap;
13use std::sync::Arc;
14use tokio::sync::{RwLock, Semaphore};
15use tracing::{debug, trace};
16use url::Url;
17
18pub type KibanaVersion = Version;
20
21pub fn parse_kibana_version(version: &str) -> Result<KibanaVersion> {
25 let trimmed = version.trim().trim_start_matches('v');
26
27 if let Ok(parsed) = KibanaVersion::parse(trimmed) {
28 return Ok(parsed);
29 }
30
31 let dot_count = trimmed.matches('.').count();
32 let normalized = match dot_count {
33 0 => format!("{trimmed}.0.0"),
34 1 => format!("{trimmed}.0"),
35 _ => trimmed.to_string(),
36 };
37
38 KibanaVersion::parse(&normalized).map_err(Error::from)
39}
40
41#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct KibanaVersionInfo {
44 pub raw: String,
45 pub parsed: KibanaVersion,
46}
47
48pub type SpaceRegistry = HashMap<String, String>;
50
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub enum ApiCapability {
54 Spaces,
55 SavedObjects,
56 Agents,
57 Tools,
58 Skills,
59 Workflows,
60}
61
62impl ApiCapability {
63 pub fn name(self) -> &'static str {
64 match self {
65 Self::Spaces => "spaces",
66 Self::SavedObjects => "saved_objects",
67 Self::Agents => "agents",
68 Self::Tools => "tools",
69 Self::Skills => "skills",
70 Self::Workflows => "workflows",
71 }
72 }
73
74 pub fn minimum_version(self) -> KibanaVersion {
75 match self {
76 Self::Spaces | Self::SavedObjects => KibanaVersion::new(8, 0, 0),
77 Self::Agents | Self::Tools => KibanaVersion::new(9, 2, 0),
78 Self::Skills => KibanaVersion::new(9, 4, 0),
79 Self::Workflows => KibanaVersion::new(9, 3, 0),
80 }
81 }
82
83 pub fn maturity_note(self) -> Option<&'static str> {
84 match self {
85 Self::Agents | Self::Tools => Some("Tech preview in 9.2, GA in 9.3"),
86 Self::Skills => Some("Experimental as of 9.4"),
87 Self::Workflows => Some("Tech preview in 9.3"),
88 _ => None,
89 }
90 }
91}
92
93#[derive(Clone, Debug)]
126pub struct KibanaClient {
127 client: Client,
128 url: Url,
129 spaces: HashMap<String, String>, space: Option<String>, semaphore: Arc<Semaphore>, version_info: Arc<RwLock<Option<KibanaVersionInfo>>>,
133}
134
135#[derive(Clone, Debug)]
137pub struct KibanaClientBuilder {
138 url: Url,
139 auth: Auth,
140 max_concurrency: usize,
141 spaces: SpaceRegistry,
142}
143
144impl KibanaClientBuilder {
145 fn new(url: Url) -> Self {
146 Self {
147 url,
148 auth: Auth::None,
149 max_concurrency: 8,
150 spaces: default_spaces(),
151 }
152 }
153
154 pub fn auth(mut self, auth: Auth) -> Self {
156 self.auth = auth;
157 self
158 }
159
160 pub fn max_concurrency(mut self, max_concurrency: usize) -> Self {
162 self.max_concurrency = max_concurrency;
163 self
164 }
165
166 pub fn spaces(mut self, spaces: impl IntoIterator<Item = (String, String)>) -> Self {
168 self.spaces = spaces.into_iter().collect();
169 if self.spaces.is_empty() {
170 self.spaces = default_spaces();
171 }
172 self
173 }
174
175 pub fn build(self) -> Result<KibanaClient> {
177 if self.max_concurrency == 0 {
178 return Err(Error::InvalidConfiguration(
179 "max_concurrency must be greater than zero".to_string(),
180 ));
181 }
182
183 let mut headers = reqwest::header::HeaderMap::new();
184 headers.insert("kbn-xsrf", "true".parse()?);
185 match self.auth {
186 Auth::Basic(username, password) => {
187 let credentials = base64::engine::general_purpose::STANDARD
188 .encode(format!("{}:{}", username, password));
189 headers.append(
190 reqwest::header::AUTHORIZATION,
191 format!("Basic {}", credentials).parse()?,
192 );
193 }
194 Auth::Apikey(apikey) => {
195 headers.append(
196 reqwest::header::AUTHORIZATION,
197 format!("ApiKey {}", apikey).parse()?,
198 );
199 }
200 Auth::None => {
201 }
203 }
204 let client = Client::builder().default_headers(headers).build()?;
205 let semaphore = Arc::new(Semaphore::new(self.max_concurrency));
206
207 Ok(KibanaClient {
208 client,
209 url: self.url,
210 spaces: self.spaces,
211 space: None, semaphore,
213 version_info: Arc::new(RwLock::new(None)),
214 })
215 }
216}
217
218fn default_spaces() -> SpaceRegistry {
219 let mut spaces = HashMap::new();
220 spaces.insert("default".to_string(), "Default".to_string());
221 spaces
222}
223
224impl KibanaClient {
225 pub fn builder(url: Url) -> KibanaClientBuilder {
227 KibanaClientBuilder::new(url)
228 }
229
230 pub fn new(url: Url, auth: Auth) -> Result<Self> {
232 Self::builder(url).auth(auth).build()
233 }
234
235 pub fn space(&self, id: &str) -> Result<KibanaClient> {
264 if !self.spaces.contains_key(id) {
265 let mut available = self.spaces.keys().cloned().collect::<Vec<_>>();
266 available.sort();
267 return Err(Error::InvalidSpace {
268 id: id.to_string(),
269 available,
270 });
271 }
272
273 let space = if id == "default" {
274 None
275 } else {
276 Some(id.to_string())
277 };
278
279 Ok(KibanaClient {
280 client: self.client.clone(),
281 url: self.url.clone(),
282 spaces: self.spaces.clone(),
283 space,
284 semaphore: self.semaphore.clone(),
285 version_info: self.version_info.clone(),
286 })
287 }
288
289 pub async fn server_version_info(&self) -> Result<KibanaVersionInfo> {
291 if let Some(cached) = self.version_info.read().await.clone() {
292 return Ok(cached);
293 }
294
295 let response = self
296 .request_raw(Method::GET, &HashMap::new(), "/api/status", None)
297 .await?;
298
299 if !response.status().is_success() {
300 let status = response.status();
301 let body = response.text().await.unwrap_or_default();
302 return Err(Error::api_response(status, body));
303 }
304
305 let status: Value = response.json().await?;
306 let raw = status
307 .get("version")
308 .and_then(|v| v.get("number"))
309 .and_then(|v| v.as_str())
310 .ok_or(Error::RedactedStatusUnauthenticated)?
311 .to_string();
312 let parsed = parse_kibana_version(&raw)?;
313 let info = KibanaVersionInfo { raw, parsed };
314
315 *self.version_info.write().await = Some(info.clone());
316 Ok(info)
317 }
318
319 pub async fn server_version(&self) -> Result<KibanaVersion> {
321 Ok(self.server_version_info().await?.parsed)
322 }
323
324 pub fn supports_capability(version: &KibanaVersion, capability: ApiCapability) -> bool {
326 version >= &capability.minimum_version()
327 }
328
329 pub fn unsupported_capability_reason(
331 version: &KibanaVersion,
332 capability: ApiCapability,
333 ) -> String {
334 let minimum = capability.minimum_version();
335 match capability.maturity_note() {
336 Some(note) => format!(
337 "API '{}' requires Kibana {}+ (detected {}, {})",
338 capability.name(),
339 minimum,
340 version,
341 note
342 ),
343 None => format!(
344 "API '{}' requires Kibana {}+ (detected {})",
345 capability.name(),
346 minimum,
347 version
348 ),
349 }
350 }
351
352 pub fn space_id(&self) -> &str {
356 self.space.as_deref().unwrap_or("default")
357 }
358
359 pub fn is_root(&self) -> bool {
361 self.space.is_none()
362 }
363
364 pub fn space_ids(&self) -> Vec<&str> {
366 self.spaces.keys().map(|s| s.as_str()).collect()
367 }
368
369 pub fn space_name(&self, id: &str) -> Option<&str> {
374 self.spaces.get(id).map(|s| s.as_str())
375 }
376
377 pub fn has_space(&self, id: &str) -> bool {
379 self.spaces.contains_key(id)
380 }
381
382 pub fn url(&self) -> &Url {
384 &self.url
385 }
386
387 pub async fn test_connection(&self) -> Result<reqwest::Response> {
391 self.request_raw(Method::GET, &HashMap::new(), "/api/status", None)
393 .await
394 }
395
396 pub async fn request(
407 &self,
408 method: Method,
409 headers: &HashMap<String, String>,
410 path: &str,
411 body: Option<&[u8]>,
412 ) -> Result<reqwest::Response> {
413 let path_stripped = path.strip_prefix('/').unwrap_or(path);
415
416 let final_path = match &self.space {
418 Some(space) => format!("/s/{}/{}", space, path_stripped),
419 None => format!("/{}", path_stripped),
420 };
421
422 debug!(method = %method, path = %final_path, "sending Kibana request");
423
424 self.request_raw(method, headers, &final_path, body).await
425 }
426
427 #[cfg(test)]
428 pub(crate) fn prefixed_path_for_test(&self, path: &str) -> String {
429 let path_stripped = path.strip_prefix('/').unwrap_or(path);
430 match &self.space {
431 Some(space) => format!("/s/{}/{}", space, path_stripped),
432 None => format!("/{}", path_stripped),
433 }
434 }
435
436 async fn request_raw(
438 &self,
439 method: Method,
440 headers: &HashMap<String, String>,
441 path: &str,
442 body: Option<&[u8]>,
443 ) -> Result<reqwest::Response> {
444 let _permit = self
445 .semaphore
446 .acquire()
447 .await
448 .map_err(|_| Error::SemaphoreClosed)?;
449
450 let mut header_map = reqwest::header::HeaderMap::new();
451 for (key, value) in headers {
452 header_map.insert(
453 key.parse::<reqwest::header::HeaderName>()?,
454 value.parse::<reqwest::header::HeaderValue>()?,
455 );
456 }
457 let use_form_data = match headers.get("Content-Type") {
458 Some(content_type) => {
459 trace!("Content-Type: {}", content_type);
460 content_type.starts_with("multipart/form-data")
461 }
462 None => false,
463 };
464
465 if use_form_data {
466 header_map.remove("Content-Type");
467 }
468
469 let client = match path.split_once('?') {
470 Some((p, query)) => {
471 let query: Vec<_> = query.split('&').filter_map(|s| s.split_once('=')).collect();
472 self.client
473 .request(method, self.url.join(p)?)
474 .query(&query)
475 .headers(header_map)
476 }
477 None => self
478 .client
479 .request(method, self.url.join(path)?)
480 .headers(header_map),
481 };
482
483 let response = match body {
484 Some(body) if use_form_data => {
485 trace!("sending Kibana request with form-data");
486 let part = multipart::Part::bytes(body.to_vec())
487 .file_name("dashboards.ndjson")
488 .mime_str("application/x-ndjson")?;
489 let form = multipart::Form::new().part("file", part);
490 client.multipart(form).send().await
491 }
492 Some(body) => {
493 trace!("sending Kibana request with body");
494 client.body(body.to_vec()).send().await
495 }
496 None => client.send().await,
497 };
498 response.map_err(Error::from)
499 }
500
501 pub async fn get(&self, path: &str) -> Result<reqwest::Response> {
503 self.request(Method::GET, &HashMap::new(), path, None).await
504 }
505
506 pub async fn get_internal(&self, path: &str) -> Result<reqwest::Response> {
509 let mut headers = HashMap::new();
510 headers.insert(
511 "X-Elastic-Internal-Origin".to_string(),
512 "Kibana".to_string(),
513 );
514 self.request(Method::GET, &headers, path, None).await
515 }
516
517 pub async fn head(&self, path: &str) -> Result<reqwest::Response> {
519 self.request(Method::HEAD, &HashMap::new(), path, None)
520 .await
521 }
522
523 pub async fn head_internal(&self, path: &str) -> Result<reqwest::Response> {
526 let mut headers = HashMap::new();
527 headers.insert(
528 "X-Elastic-Internal-Origin".to_string(),
529 "Kibana".to_string(),
530 );
531 self.request(Method::HEAD, &headers, path, None).await
532 }
533
534 pub async fn post_json(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
536 let mut headers = HashMap::new();
537 headers.insert("Content-Type".to_string(), "application/json".to_string());
538 self.request(Method::POST, &headers, path, Some(body)).await
539 }
540
541 pub async fn post_json_value(
543 &self,
544 path: &str,
545 value: &serde_json::Value,
546 ) -> Result<reqwest::Response> {
547 let body = serde_json::to_vec(value)?;
548 self.post_json(path, &body).await
549 }
550
551 pub async fn post_json_value_internal(
554 &self,
555 path: &str,
556 value: &serde_json::Value,
557 ) -> Result<reqwest::Response> {
558 let body = serde_json::to_vec(value)?;
559 let mut headers = HashMap::new();
560 headers.insert("Content-Type".to_string(), "application/json".to_string());
561 headers.insert(
562 "X-Elastic-Internal-Origin".to_string(),
563 "Kibana".to_string(),
564 );
565 self.request(Method::POST, &headers, path, Some(&body))
566 .await
567 }
568
569 pub async fn put_json_value(
571 &self,
572 path: &str,
573 value: &serde_json::Value,
574 ) -> Result<reqwest::Response> {
575 let body = serde_json::to_vec(value)?;
576 let mut headers = HashMap::new();
577 headers.insert("Content-Type".to_string(), "application/json".to_string());
578 self.request(Method::PUT, &headers, path, Some(&body)).await
579 }
580
581 pub async fn put_json_value_internal(
584 &self,
585 path: &str,
586 value: &serde_json::Value,
587 ) -> Result<reqwest::Response> {
588 let body = serde_json::to_vec(value)?;
589 let mut headers = HashMap::new();
590 headers.insert("Content-Type".to_string(), "application/json".to_string());
591 headers.insert(
592 "X-Elastic-Internal-Origin".to_string(),
593 "Kibana".to_string(),
594 );
595 self.request(Method::PUT, &headers, path, Some(&body)).await
596 }
597
598 pub async fn post_form(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
600 let mut headers = HashMap::new();
601 headers.insert(
602 "Content-Type".to_string(),
603 "multipart/form-data".to_string(),
604 );
605 self.request(Method::POST, &headers, path, Some(body)).await
606 }
607}
608
609impl std::fmt::Display for KibanaClient {
610 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
611 match &self.space {
612 Some(space) => write!(f, "{} (space: {})", self.url, space),
613 None => write!(f, "{}", self.url),
614 }
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use crate::test_support::{MockResponse, TestServer};
622 use serde_json::json;
623
624 #[test]
625 fn test_kibana_client_defaults_to_default_space_without_filesystem() {
626 let url = Url::parse("http://localhost:5601").unwrap();
627 let client = KibanaClient::builder(url).build().unwrap();
628
629 assert_eq!(client.space_ids().len(), 1);
630 assert!(client.has_space("default"));
631 assert_eq!(client.space_name("default"), Some("Default"));
632 assert!(client.is_root());
633 assert_eq!(client.space_id(), "default");
634 }
635
636 #[test]
637 fn test_kibana_client_with_explicit_space_registry() {
638 let url = Url::parse("http://localhost:5601").unwrap();
639 let client = KibanaClient::builder(url)
640 .spaces([
641 ("default".to_string(), "Default".to_string()),
642 ("marketing".to_string(), "Marketing".to_string()),
643 ])
644 .build()
645 .unwrap();
646
647 assert_eq!(client.space_ids().len(), 2);
648 assert!(client.has_space("default"));
649 assert!(client.has_space("marketing"));
650 assert_eq!(client.space_name("marketing"), Some("Marketing"));
651 }
652
653 #[test]
654 fn test_space_scoped_client() {
655 let url = Url::parse("http://localhost:5601").unwrap();
656 let client = KibanaClient::builder(url)
657 .spaces([
658 ("default".to_string(), "Default".to_string()),
659 ("marketing".to_string(), "Marketing".to_string()),
660 ])
661 .build()
662 .unwrap();
663
664 assert!(client.is_root());
666 assert_eq!(client.space_id(), "default");
667
668 let default = client.space("default").unwrap();
670 assert_eq!(default.space_id(), "default");
671 assert!(default.space.is_none());
672 assert_eq!(
673 default.prefixed_path_for_test("/api/saved_objects"),
674 "/api/saved_objects"
675 );
676
677 let marketing = client.space("marketing").unwrap();
679 assert_eq!(marketing.space_id(), "marketing");
680 assert!(!marketing.is_root());
681 assert!(marketing.space.is_some());
682 assert_eq!(
683 marketing.prefixed_path_for_test("/api/saved_objects"),
684 "/s/marketing/api/saved_objects"
685 );
686
687 assert!(marketing.has_space("default"));
689 assert!(marketing.has_space("marketing"));
690 }
691
692 #[test]
693 fn test_invalid_space() {
694 let url = Url::parse("http://localhost:5601").unwrap();
695 let client = KibanaClient::builder(url).build().unwrap();
696
697 let result = client.space("nonexistent");
698 assert!(result.is_err());
699 assert!(matches!(result.unwrap_err(), Error::InvalidSpace { .. }));
700 }
701
702 #[test]
703 fn test_parse_kibana_version() {
704 let parsed = parse_kibana_version("9.3.2").unwrap();
705 assert_eq!(parsed, KibanaVersion::new(9, 3, 2));
706
707 let snapshot = parse_kibana_version("9.4.0-SNAPSHOT").unwrap();
708 assert_eq!(snapshot, KibanaVersion::parse("9.4.0-SNAPSHOT").unwrap());
709
710 let prefixed = parse_kibana_version("v9.5.1").unwrap();
711 assert_eq!(prefixed, KibanaVersion::new(9, 5, 1));
712
713 let missing_patch = parse_kibana_version("9.6").unwrap();
714 assert_eq!(missing_patch, KibanaVersion::new(9, 6, 0));
715 }
716
717 #[test]
718 fn test_capability_thresholds() {
719 let v92 = parse_kibana_version("9.2.1").unwrap();
720 let v91 = parse_kibana_version("9.1.9").unwrap();
721 let v93 = parse_kibana_version("9.3.0").unwrap();
722
723 assert!(KibanaClient::supports_capability(
724 &v92,
725 ApiCapability::Agents
726 ));
727 assert!(KibanaClient::supports_capability(
728 &v92,
729 ApiCapability::Tools
730 ));
731 assert!(!KibanaClient::supports_capability(
732 &v91,
733 ApiCapability::Agents
734 ));
735 assert!(!KibanaClient::supports_capability(
736 &v91,
737 ApiCapability::Tools
738 ));
739 assert!(KibanaClient::supports_capability(
740 &v93,
741 ApiCapability::Workflows
742 ));
743 assert!(!KibanaClient::supports_capability(
744 &v92,
745 ApiCapability::Workflows
746 ));
747
748 let v94 = parse_kibana_version("9.4.0").unwrap();
749 assert!(KibanaClient::supports_capability(
750 &v94,
751 ApiCapability::Skills
752 ));
753 assert!(!KibanaClient::supports_capability(
754 &v93,
755 ApiCapability::Skills
756 ));
757 }
758
759 #[tokio::test]
760 async fn server_version_info_parses_authenticated_status_response() {
761 let server = TestServer::new(vec![MockResponse {
762 method: "GET",
763 path: "/api/status",
764 status: 200,
765 body: json!({
766 "version": {
767 "number": "9.4.1"
768 }
769 }),
770 }]);
771 let client = server.client().unwrap();
772
773 let info = client.server_version_info().await.unwrap();
774
775 assert_eq!(info.raw, "9.4.1");
776 assert_eq!(info.parsed, KibanaVersion::new(9, 4, 1));
777 }
778
779 #[tokio::test]
780 async fn server_version_info_reports_redacted_status_as_authentication_failure() {
781 let server = TestServer::new(vec![MockResponse {
782 method: "GET",
783 path: "/api/status",
784 status: 200,
785 body: json!({
786 "status": {
787 "overall": {
788 "level": "available"
789 }
790 }
791 }),
792 }]);
793 let client = server.client().unwrap();
794
795 let err = client.server_version_info().await.unwrap_err();
796
797 assert!(matches!(err, Error::RedactedStatusUnauthenticated));
798 assert!(err.to_string().contains("Check KIBANA_APIKEY"));
799 }
800}