use reqwest::Method;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use crate::{
Authenticated, Client, Result,
path::{validate_mount_path, validate_secret_path},
response::{Empty, ResponseEnvelope},
};
#[derive(Debug)]
pub struct Kv2<'a> {
client: &'a Client<Authenticated>,
mount: Vec<String>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
pub struct Kv2WriteOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub cas: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Kv2Secret<T> {
pub data: T,
pub metadata: Kv2Metadata,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Kv2Metadata {
pub created_time: String,
#[serde(default)]
pub deletion_time: String,
#[serde(default)]
pub destroyed: bool,
pub version: u64,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Kv2WriteResponse {
pub created_time: String,
pub version: u64,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Kv2List {
#[serde(default)]
pub keys: Vec<String>,
}
#[derive(Deserialize)]
struct Kv2ReadEnvelope<T> {
data: Kv2Secret<T>,
}
#[derive(Serialize)]
struct Kv2WritePayload<T> {
data: T,
#[serde(skip_serializing_if = "Option::is_none")]
options: Option<Kv2WriteOptions>,
}
impl Client<Authenticated> {
pub fn kv2(&self, mount: impl Into<String>) -> Result<Kv2<'_>> {
let mount = mount.into();
Ok(Kv2 {
client: self,
mount: validate_mount_path(&mount)?,
})
}
}
impl Kv2<'_> {
pub async fn read<T>(&self, path: &str) -> Result<Kv2Secret<T>>
where
T: DeserializeOwned,
{
let envelope: Kv2ReadEnvelope<T> = self
.client
.request_json(Method::GET, &self.data_path(path)?, Option::<&Empty>::None)
.await?;
Ok(envelope.data)
}
pub async fn write<T>(&self, path: &str, data: T) -> Result<Kv2WriteResponse>
where
T: Serialize,
{
self.write_with_options(path, data, None).await
}
pub async fn write_with_options<T>(
&self,
path: &str,
data: T,
options: Option<Kv2WriteOptions>,
) -> Result<Kv2WriteResponse>
where
T: Serialize,
{
let payload = Kv2WritePayload { data, options };
let envelope: ResponseEnvelope<Kv2WriteResponse> = self
.client
.request_json(Method::POST, &self.data_path(path)?, Some(&payload))
.await?;
Ok(envelope.data)
}
pub async fn delete_latest(&self, path: &str) -> Result<Empty> {
self.client
.request_json(
Method::DELETE,
&self.data_path(path)?,
Option::<&Empty>::None,
)
.await
}
pub async fn list(&self, path: &str) -> Result<Kv2List> {
let method = Method::from_bytes(b"LIST")
.map_err(|error| crate::Error::InvalidHeader(error.to_string()))?;
let envelope: ResponseEnvelope<Kv2List> = self
.client
.request_json(method, &self.metadata_path(path)?, Option::<&Empty>::None)
.await?;
Ok(envelope.data)
}
fn data_path(&self, path: &str) -> Result<String> {
let mut segments = self.mount.clone();
segments.push("data".to_owned());
segments.extend(validate_secret_path(path)?);
Ok(segments.join("/"))
}
fn metadata_path(&self, path: &str) -> Result<String> {
let mut segments = self.mount.clone();
segments.push("metadata".to_owned());
segments.extend(validate_secret_path(path)?);
Ok(segments.join("/"))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic)]
use secrecy::SecretString;
use crate::{Client, OpenBaoConfig};
#[test]
fn kv2_paths_are_validated() {
let config = OpenBaoConfig::new("http://127.0.0.1:8200")
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("token"));
let kv = client
.kv2("secret")
.unwrap_or_else(|error| panic!("{error}"));
assert!(kv.data_path("app/config").is_ok());
assert!(kv.data_path("../config").is_err());
}
#[test]
fn kv2_validates_mount_at_construction() {
let config = OpenBaoConfig::new("http://127.0.0.1:8200")
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("token"));
assert!(client.kv2("../secret").is_err());
}
}