use crate::schema::canonical::error::ParseError;
use crate::schema::schema_value::SecretValuePayload;
use serde_json::{Map, Value};
const TEXT_PREFIX: &str = "secret:";
pub fn to_text(payload: &SecretValuePayload) -> Result<String, ParseError> {
let json = to_json(payload)?;
Ok(format!("{TEXT_PREFIX}{json}"))
}
pub fn from_text(s: &str) -> Result<SecretValuePayload, ParseError> {
if s.is_empty() {
return Err(ParseError::Empty);
}
let rest = s
.strip_prefix(TEXT_PREFIX)
.ok_or_else(|| ParseError::BadFormat("missing 'secret:' prefix".into()))?;
if rest.is_empty() {
return Err(ParseError::Empty);
}
let value = serde_json::from_str(rest).map_err(|e| ParseError::BadFormat(e.to_string()))?;
from_json(&value)
}
pub fn to_json(payload: &SecretValuePayload) -> Result<Value, ParseError> {
let mut obj = Map::new();
obj.insert(
"secretId".to_string(),
Value::String(payload.secret_id.to_string()),
);
if let Some(config_key) = &payload.config_key {
obj.insert(
"configKey".to_string(),
Value::Array(config_key.iter().cloned().map(Value::String).collect()),
);
}
obj.insert("version".to_string(), Value::Number(payload.version.into()));
obj.insert(
"resolvedAt".to_string(),
Value::String(payload.resolved_at.to_rfc3339()),
);
if let Some(category) = &payload.category {
obj.insert("category".to_string(), Value::String(category.clone()));
}
Ok(Value::Object(obj))
}
pub fn from_json(value: &Value) -> Result<SecretValuePayload, ParseError> {
let obj = value.as_object().ok_or(ParseError::TypeField {
expected: "object",
field: None,
})?;
let secret_id = obj
.get("secretId")
.ok_or(ParseError::MissingField("secretId"))?
.as_str()
.ok_or(ParseError::TypeField {
expected: "string",
field: Some("secretId"),
})?
.parse()
.map_err(|e: uuid::Error| ParseError::BadFormat(e.to_string()))?;
let config_key = match obj.get("configKey") {
Some(Value::Array(items)) => Some(
items
.iter()
.map(|v| {
v.as_str().map(str::to_string).ok_or(ParseError::TypeField {
expected: "string",
field: Some("configKey"),
})
})
.collect::<Result<Vec<_>, _>>()?,
),
Some(_) => {
return Err(ParseError::TypeField {
expected: "array",
field: Some("configKey"),
});
}
None => None,
};
let version = obj
.get("version")
.ok_or(ParseError::MissingField("version"))?
.as_u64()
.ok_or(ParseError::TypeField {
expected: "u64",
field: Some("version"),
})?;
let resolved_at = obj
.get("resolvedAt")
.ok_or(ParseError::MissingField("resolvedAt"))?
.as_str()
.ok_or(ParseError::TypeField {
expected: "string",
field: Some("resolvedAt"),
})?
.parse::<chrono::DateTime<chrono::Utc>>()
.map_err(|e| ParseError::BadFormat(e.to_string()))?;
let category = match obj.get("category") {
Some(Value::String(s)) => Some(s.clone()),
Some(_) => {
return Err(ParseError::TypeField {
expected: "string",
field: Some("category"),
});
}
None => None,
};
for key in obj.keys() {
if !matches!(
key.as_str(),
"secretId" | "configKey" | "version" | "resolvedAt" | "category"
) {
return Err(ParseError::ExtraField(key.clone()));
}
}
Ok(SecretValuePayload {
secret_id,
config_key,
version,
resolved_at,
category,
})
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use test_r::test;
fn payload_strategy() -> impl Strategy<Value = SecretValuePayload> {
(
any::<u128>(),
prop::option::of(prop::collection::vec("[A-Za-z0-9_-]{1,12}", 0..3)),
any::<u64>(),
prop::option::of("[A-Za-z0-9_-]{1,12}"),
)
.prop_map(|(id, config_key, version, category)| SecretValuePayload {
secret_id: uuid::Uuid::from_u128(id),
config_key,
version,
resolved_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
category,
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn text_round_trip(p in payload_strategy()) {
let s = to_text(&p).expect("to_text");
let back = from_text(&s).expect("from_text");
prop_assert_eq!(back, p);
}
#[test]
fn json_round_trip(p in payload_strategy()) {
let j = to_json(&p).expect("to_json");
let back = from_json(&j).expect("from_json");
prop_assert_eq!(back, p);
}
}
#[test]
fn empty_text_rejected() {
assert_eq!(from_text(""), Err(ParseError::Empty));
}
#[test]
fn missing_prefix_rejected() {
assert!(matches!(from_text("plain"), Err(ParseError::BadFormat(_))));
}
#[test]
fn prefix_only_rejected() {
assert_eq!(from_text("secret:"), Err(ParseError::Empty));
}
#[test]
fn json_wrong_type() {
assert_eq!(
from_json(&Value::String("x".into())),
Err(ParseError::TypeField {
expected: "object",
field: None,
})
);
}
#[test]
fn json_missing_field() {
assert_eq!(
from_json(&serde_json::json!({})),
Err(ParseError::MissingField("secretId"))
);
}
#[test]
fn json_extra_field_rejected() {
let v = serde_json::json!({
"secretId": uuid::Uuid::nil().to_string(),
"version": 0,
"resolvedAt": "1970-01-01T00:00:00Z",
"extra": true
});
assert_eq!(from_json(&v), Err(ParseError::ExtraField("extra".into())));
}
#[test]
fn json_wrong_secret_id_type_rejected() {
let v = serde_json::json!({ "secretId": 42 });
assert_eq!(
from_json(&v),
Err(ParseError::TypeField {
expected: "string",
field: Some("secretId"),
})
);
}
}