use super::Auth;
use crate::{Error, Result};
use base64::Engine;
use reqwest::{Client, Method, multipart};
use semver::Version;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{RwLock, Semaphore};
use tracing::{debug, trace};
use url::Url;
pub type KibanaVersion = Version;
pub fn parse_kibana_version(version: &str) -> Result<KibanaVersion> {
let trimmed = version.trim().trim_start_matches('v');
if let Ok(parsed) = KibanaVersion::parse(trimmed) {
return Ok(parsed);
}
let dot_count = trimmed.matches('.').count();
let normalized = match dot_count {
0 => format!("{trimmed}.0.0"),
1 => format!("{trimmed}.0"),
_ => trimmed.to_string(),
};
KibanaVersion::parse(&normalized).map_err(Error::from)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct KibanaVersionInfo {
pub raw: String,
pub parsed: KibanaVersion,
}
pub type SpaceRegistry = HashMap<String, String>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ApiCapability {
Spaces,
SavedObjects,
Agents,
Tools,
Skills,
Workflows,
}
impl ApiCapability {
pub fn name(self) -> &'static str {
match self {
Self::Spaces => "spaces",
Self::SavedObjects => "saved_objects",
Self::Agents => "agents",
Self::Tools => "tools",
Self::Skills => "skills",
Self::Workflows => "workflows",
}
}
pub fn minimum_version(self) -> KibanaVersion {
match self {
Self::Spaces | Self::SavedObjects => KibanaVersion::new(8, 0, 0),
Self::Agents | Self::Tools => KibanaVersion::new(9, 2, 0),
Self::Skills => KibanaVersion::new(9, 4, 0),
Self::Workflows => KibanaVersion::new(9, 3, 0),
}
}
pub fn maturity_note(self) -> Option<&'static str> {
match self {
Self::Agents | Self::Tools => Some("Tech preview in 9.2, GA in 9.3"),
Self::Skills => Some("Experimental as of 9.4"),
Self::Workflows => Some("Tech preview in 9.3"),
_ => None,
}
}
}
#[derive(Clone, Debug)]
pub struct KibanaClient {
client: Client,
url: Url,
spaces: HashMap<String, String>, space: Option<String>, semaphore: Arc<Semaphore>, version_info: Arc<RwLock<Option<KibanaVersionInfo>>>,
}
#[derive(Clone, Debug)]
pub struct KibanaClientBuilder {
url: Url,
auth: Auth,
max_concurrency: usize,
spaces: SpaceRegistry,
}
impl KibanaClientBuilder {
fn new(url: Url) -> Self {
Self {
url,
auth: Auth::None,
max_concurrency: 8,
spaces: default_spaces(),
}
}
pub fn auth(mut self, auth: Auth) -> Self {
self.auth = auth;
self
}
pub fn max_concurrency(mut self, max_concurrency: usize) -> Self {
self.max_concurrency = max_concurrency;
self
}
pub fn spaces(mut self, spaces: impl IntoIterator<Item = (String, String)>) -> Self {
self.spaces = spaces.into_iter().collect();
if self.spaces.is_empty() {
self.spaces = default_spaces();
}
self
}
pub fn build(self) -> Result<KibanaClient> {
if self.max_concurrency == 0 {
return Err(Error::InvalidConfiguration(
"max_concurrency must be greater than zero".to_string(),
));
}
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("kbn-xsrf", "true".parse()?);
match self.auth {
Auth::Basic(username, password) => {
let credentials = base64::engine::general_purpose::STANDARD
.encode(format!("{}:{}", username, password));
headers.append(
reqwest::header::AUTHORIZATION,
format!("Basic {}", credentials).parse()?,
);
}
Auth::Apikey(apikey) => {
headers.append(
reqwest::header::AUTHORIZATION,
format!("ApiKey {}", apikey).parse()?,
);
}
Auth::None => {
}
}
let client = Client::builder().default_headers(headers).build()?;
let semaphore = Arc::new(Semaphore::new(self.max_concurrency));
Ok(KibanaClient {
client,
url: self.url,
spaces: self.spaces,
space: None, semaphore,
version_info: Arc::new(RwLock::new(None)),
})
}
}
fn default_spaces() -> SpaceRegistry {
let mut spaces = HashMap::new();
spaces.insert("default".to_string(), "Default".to_string());
spaces
}
impl KibanaClient {
pub fn builder(url: Url) -> KibanaClientBuilder {
KibanaClientBuilder::new(url)
}
pub fn new(url: Url, auth: Auth) -> Result<Self> {
Self::builder(url).auth(auth).build()
}
pub fn space(&self, id: &str) -> Result<KibanaClient> {
if !self.spaces.contains_key(id) {
let mut available = self.spaces.keys().cloned().collect::<Vec<_>>();
available.sort();
return Err(Error::InvalidSpace {
id: id.to_string(),
available,
});
}
let space = if id == "default" {
None
} else {
Some(id.to_string())
};
Ok(KibanaClient {
client: self.client.clone(),
url: self.url.clone(),
spaces: self.spaces.clone(),
space,
semaphore: self.semaphore.clone(),
version_info: self.version_info.clone(),
})
}
pub async fn server_version_info(&self) -> Result<KibanaVersionInfo> {
if let Some(cached) = self.version_info.read().await.clone() {
return Ok(cached);
}
let response = self
.request_raw(Method::GET, &HashMap::new(), "/api/status", None)
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(Error::api_response(status, body));
}
let status: Value = response.json().await?;
let raw = status
.get("version")
.and_then(|v| v.get("number"))
.and_then(|v| v.as_str())
.ok_or(Error::RedactedStatusUnauthenticated)?
.to_string();
let parsed = parse_kibana_version(&raw)?;
let info = KibanaVersionInfo { raw, parsed };
*self.version_info.write().await = Some(info.clone());
Ok(info)
}
pub async fn server_version(&self) -> Result<KibanaVersion> {
Ok(self.server_version_info().await?.parsed)
}
pub fn supports_capability(version: &KibanaVersion, capability: ApiCapability) -> bool {
version >= &capability.minimum_version()
}
pub fn unsupported_capability_reason(
version: &KibanaVersion,
capability: ApiCapability,
) -> String {
let minimum = capability.minimum_version();
match capability.maturity_note() {
Some(note) => format!(
"API '{}' requires Kibana {}+ (detected {}, {})",
capability.name(),
minimum,
version,
note
),
None => format!(
"API '{}' requires Kibana {}+ (detected {})",
capability.name(),
minimum,
version
),
}
}
pub fn space_id(&self) -> &str {
self.space.as_deref().unwrap_or("default")
}
pub fn is_root(&self) -> bool {
self.space.is_none()
}
pub fn space_ids(&self) -> Vec<&str> {
self.spaces.keys().map(|s| s.as_str()).collect()
}
pub fn space_name(&self, id: &str) -> Option<&str> {
self.spaces.get(id).map(|s| s.as_str())
}
pub fn has_space(&self, id: &str) -> bool {
self.spaces.contains_key(id)
}
pub fn url(&self) -> &Url {
&self.url
}
pub async fn test_connection(&self) -> Result<reqwest::Response> {
self.request_raw(Method::GET, &HashMap::new(), "/api/status", None)
.await
}
pub async fn request(
&self,
method: Method,
headers: &HashMap<String, String>,
path: &str,
body: Option<&[u8]>,
) -> Result<reqwest::Response> {
let path_stripped = path.strip_prefix('/').unwrap_or(path);
let final_path = match &self.space {
Some(space) => format!("/s/{}/{}", space, path_stripped),
None => format!("/{}", path_stripped),
};
debug!(method = %method, path = %final_path, "sending Kibana request");
self.request_raw(method, headers, &final_path, body).await
}
#[cfg(test)]
pub(crate) fn prefixed_path_for_test(&self, path: &str) -> String {
let path_stripped = path.strip_prefix('/').unwrap_or(path);
match &self.space {
Some(space) => format!("/s/{}/{}", space, path_stripped),
None => format!("/{}", path_stripped),
}
}
async fn request_raw(
&self,
method: Method,
headers: &HashMap<String, String>,
path: &str,
body: Option<&[u8]>,
) -> Result<reqwest::Response> {
let _permit = self
.semaphore
.acquire()
.await
.map_err(|_| Error::SemaphoreClosed)?;
let mut header_map = reqwest::header::HeaderMap::new();
for (key, value) in headers {
header_map.insert(
key.parse::<reqwest::header::HeaderName>()?,
value.parse::<reqwest::header::HeaderValue>()?,
);
}
let use_form_data = match headers.get("Content-Type") {
Some(content_type) => {
trace!("Content-Type: {}", content_type);
content_type.starts_with("multipart/form-data")
}
None => false,
};
if use_form_data {
header_map.remove("Content-Type");
}
let client = match path.split_once('?') {
Some((p, query)) => {
let query: Vec<_> = query.split('&').filter_map(|s| s.split_once('=')).collect();
self.client
.request(method, self.url.join(p)?)
.query(&query)
.headers(header_map)
}
None => self
.client
.request(method, self.url.join(path)?)
.headers(header_map),
};
let response = match body {
Some(body) if use_form_data => {
trace!("sending Kibana request with form-data");
let part = multipart::Part::bytes(body.to_vec())
.file_name("dashboards.ndjson")
.mime_str("application/x-ndjson")?;
let form = multipart::Form::new().part("file", part);
client.multipart(form).send().await
}
Some(body) => {
trace!("sending Kibana request with body");
client.body(body.to_vec()).send().await
}
None => client.send().await,
};
response.map_err(Error::from)
}
pub async fn get(&self, path: &str) -> Result<reqwest::Response> {
self.request(Method::GET, &HashMap::new(), path, None).await
}
pub async fn get_internal(&self, path: &str) -> Result<reqwest::Response> {
let mut headers = HashMap::new();
headers.insert(
"X-Elastic-Internal-Origin".to_string(),
"Kibana".to_string(),
);
self.request(Method::GET, &headers, path, None).await
}
pub async fn head(&self, path: &str) -> Result<reqwest::Response> {
self.request(Method::HEAD, &HashMap::new(), path, None)
.await
}
pub async fn head_internal(&self, path: &str) -> Result<reqwest::Response> {
let mut headers = HashMap::new();
headers.insert(
"X-Elastic-Internal-Origin".to_string(),
"Kibana".to_string(),
);
self.request(Method::HEAD, &headers, path, None).await
}
pub async fn post_json(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
self.request(Method::POST, &headers, path, Some(body)).await
}
pub async fn post_json_value(
&self,
path: &str,
value: &serde_json::Value,
) -> Result<reqwest::Response> {
let body = serde_json::to_vec(value)?;
self.post_json(path, &body).await
}
pub async fn post_json_value_internal(
&self,
path: &str,
value: &serde_json::Value,
) -> Result<reqwest::Response> {
let body = serde_json::to_vec(value)?;
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
headers.insert(
"X-Elastic-Internal-Origin".to_string(),
"Kibana".to_string(),
);
self.request(Method::POST, &headers, path, Some(&body))
.await
}
pub async fn put_json_value(
&self,
path: &str,
value: &serde_json::Value,
) -> Result<reqwest::Response> {
let body = serde_json::to_vec(value)?;
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
self.request(Method::PUT, &headers, path, Some(&body)).await
}
pub async fn put_json_value_internal(
&self,
path: &str,
value: &serde_json::Value,
) -> Result<reqwest::Response> {
let body = serde_json::to_vec(value)?;
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
headers.insert(
"X-Elastic-Internal-Origin".to_string(),
"Kibana".to_string(),
);
self.request(Method::PUT, &headers, path, Some(&body)).await
}
pub async fn post_form(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
let mut headers = HashMap::new();
headers.insert(
"Content-Type".to_string(),
"multipart/form-data".to_string(),
);
self.request(Method::POST, &headers, path, Some(body)).await
}
}
impl std::fmt::Display for KibanaClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.space {
Some(space) => write!(f, "{} (space: {})", self.url, space),
None => write!(f, "{}", self.url),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{MockResponse, TestServer};
use serde_json::json;
#[test]
fn test_kibana_client_defaults_to_default_space_without_filesystem() {
let url = Url::parse("http://localhost:5601").unwrap();
let client = KibanaClient::builder(url).build().unwrap();
assert_eq!(client.space_ids().len(), 1);
assert!(client.has_space("default"));
assert_eq!(client.space_name("default"), Some("Default"));
assert!(client.is_root());
assert_eq!(client.space_id(), "default");
}
#[test]
fn test_kibana_client_with_explicit_space_registry() {
let url = Url::parse("http://localhost:5601").unwrap();
let client = KibanaClient::builder(url)
.spaces([
("default".to_string(), "Default".to_string()),
("marketing".to_string(), "Marketing".to_string()),
])
.build()
.unwrap();
assert_eq!(client.space_ids().len(), 2);
assert!(client.has_space("default"));
assert!(client.has_space("marketing"));
assert_eq!(client.space_name("marketing"), Some("Marketing"));
}
#[test]
fn test_space_scoped_client() {
let url = Url::parse("http://localhost:5601").unwrap();
let client = KibanaClient::builder(url)
.spaces([
("default".to_string(), "Default".to_string()),
("marketing".to_string(), "Marketing".to_string()),
])
.build()
.unwrap();
assert!(client.is_root());
assert_eq!(client.space_id(), "default");
let default = client.space("default").unwrap();
assert_eq!(default.space_id(), "default");
assert!(default.space.is_none());
assert_eq!(
default.prefixed_path_for_test("/api/saved_objects"),
"/api/saved_objects"
);
let marketing = client.space("marketing").unwrap();
assert_eq!(marketing.space_id(), "marketing");
assert!(!marketing.is_root());
assert!(marketing.space.is_some());
assert_eq!(
marketing.prefixed_path_for_test("/api/saved_objects"),
"/s/marketing/api/saved_objects"
);
assert!(marketing.has_space("default"));
assert!(marketing.has_space("marketing"));
}
#[test]
fn test_invalid_space() {
let url = Url::parse("http://localhost:5601").unwrap();
let client = KibanaClient::builder(url).build().unwrap();
let result = client.space("nonexistent");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidSpace { .. }));
}
#[test]
fn test_parse_kibana_version() {
let parsed = parse_kibana_version("9.3.2").unwrap();
assert_eq!(parsed, KibanaVersion::new(9, 3, 2));
let snapshot = parse_kibana_version("9.4.0-SNAPSHOT").unwrap();
assert_eq!(snapshot, KibanaVersion::parse("9.4.0-SNAPSHOT").unwrap());
let prefixed = parse_kibana_version("v9.5.1").unwrap();
assert_eq!(prefixed, KibanaVersion::new(9, 5, 1));
let missing_patch = parse_kibana_version("9.6").unwrap();
assert_eq!(missing_patch, KibanaVersion::new(9, 6, 0));
}
#[test]
fn test_capability_thresholds() {
let v92 = parse_kibana_version("9.2.1").unwrap();
let v91 = parse_kibana_version("9.1.9").unwrap();
let v93 = parse_kibana_version("9.3.0").unwrap();
assert!(KibanaClient::supports_capability(
&v92,
ApiCapability::Agents
));
assert!(KibanaClient::supports_capability(
&v92,
ApiCapability::Tools
));
assert!(!KibanaClient::supports_capability(
&v91,
ApiCapability::Agents
));
assert!(!KibanaClient::supports_capability(
&v91,
ApiCapability::Tools
));
assert!(KibanaClient::supports_capability(
&v93,
ApiCapability::Workflows
));
assert!(!KibanaClient::supports_capability(
&v92,
ApiCapability::Workflows
));
let v94 = parse_kibana_version("9.4.0").unwrap();
assert!(KibanaClient::supports_capability(
&v94,
ApiCapability::Skills
));
assert!(!KibanaClient::supports_capability(
&v93,
ApiCapability::Skills
));
}
#[tokio::test]
async fn server_version_info_parses_authenticated_status_response() {
let server = TestServer::new(vec![MockResponse {
method: "GET",
path: "/api/status",
status: 200,
body: json!({
"version": {
"number": "9.4.1"
}
}),
}]);
let client = server.client().unwrap();
let info = client.server_version_info().await.unwrap();
assert_eq!(info.raw, "9.4.1");
assert_eq!(info.parsed, KibanaVersion::new(9, 4, 1));
}
#[tokio::test]
async fn server_version_info_reports_redacted_status_as_authentication_failure() {
let server = TestServer::new(vec![MockResponse {
method: "GET",
path: "/api/status",
status: 200,
body: json!({
"status": {
"overall": {
"level": "available"
}
}
}),
}]);
let client = server.client().unwrap();
let err = client.server_version_info().await.unwrap_err();
assert!(matches!(err, Error::RedactedStatusUnauthenticated));
assert!(err.to_string().contains("Check KIBANA_APIKEY"));
}
}