use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: &str = "1.0";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginRpcRequest {
pub jsonrpc: JsonRpcVersion,
pub id: u64,
#[serde(flatten)]
pub call: PluginRequest,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginRpcResponse {
pub jsonrpc: JsonRpcVersion,
pub id: u64,
#[serde(flatten)]
pub outcome: RpcOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RpcOutcome {
Result(PluginResponse),
Error(PluginError),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JsonRpcVersion(String);
impl JsonRpcVersion {
pub fn current() -> Self {
Self("2.0".into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn is_supported(&self) -> bool {
self.0 == "2.0"
}
}
impl Default for JsonRpcVersion {
fn default() -> Self {
Self::current()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
pub enum PluginRequest {
#[serde(rename = "secret_source.init")]
Init(InitParams),
#[serde(rename = "secret_source.is_available")]
IsAvailable,
#[serde(rename = "secret_source.get")]
Get(GetParams),
#[serde(rename = "secret_source.list")]
List,
#[serde(rename = "secret_source.validate")]
Validate(ValidateParams),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InitParams {
pub source_name: String,
pub config: BTreeMap<String, serde_json::Value>,
pub protocol_version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GetParams {
pub reference: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidateParams {
pub reference: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PluginResponse {
Init(InitResult),
IsAvailable(IsAvailableResult),
Get(GetResult),
List(ListResult),
Validate(ValidateResult),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InitResult {
pub source_name: String,
pub capabilities_bits: u32,
pub plugin_version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IsAvailableResult {
pub status: IsAvailableStatus,
pub detail: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum IsAvailableStatus {
Available,
Unavailable,
NeedsCredential,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GetResult {
pub value: String,
pub lease_seconds: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListResult {
pub entries: Vec<RemoteRefDto>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoteRefDto {
pub reference: String,
pub display: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidateResult {
pub ok: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum PluginError {
#[error("source unavailable: {detail}")]
Unavailable { detail: String },
#[error("source does not support capability: {capability}")]
UnsupportedCapability { capability: String },
#[error("source rejected reference `{reference}`: {reason}")]
BadReference { reference: String, reason: String },
#[error("source needs credential: {detail}")]
NeedsCredential { detail: String },
#[error("source error: {detail}")]
Other { detail: String },
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn req(call: PluginRequest, id: u64) -> PluginRpcRequest {
PluginRpcRequest {
jsonrpc: JsonRpcVersion::current(),
id,
call,
}
}
fn ok(id: u64, resp: PluginResponse) -> PluginRpcResponse {
PluginRpcResponse {
jsonrpc: JsonRpcVersion::current(),
id,
outcome: RpcOutcome::Result(resp),
}
}
#[test]
fn jsonrpc_version_constant_is_v2() {
assert_eq!(JsonRpcVersion::current().as_str(), "2.0");
assert!(JsonRpcVersion::current().is_supported());
}
#[test]
fn jsonrpc_version_rejects_anything_other_than_v2() {
let v = JsonRpcVersion("1.0".into());
assert!(!v.is_supported());
}
#[test]
fn init_request_round_trips_through_json() {
let mut config = BTreeMap::new();
config.insert("address".into(), json!("https://vault.example.invalid"));
let r = req(
PluginRequest::Init(InitParams {
source_name: "prod-vault".into(),
config,
protocol_version: PROTOCOL_VERSION.into(),
}),
1,
);
let line = serde_json::to_string(&r).unwrap();
assert!(line.contains("\"method\":\"secret_source.init\""));
assert!(line.contains("\"prod-vault\""));
let back: PluginRpcRequest = serde_json::from_str(&line).unwrap();
assert_eq!(back, r);
}
#[test]
fn is_available_request_has_no_params() {
let r = req(PluginRequest::IsAvailable, 7);
let line = serde_json::to_string(&r).unwrap();
assert!(line.contains("\"method\":\"secret_source.is_available\""));
let back: PluginRpcRequest = serde_json::from_str(&line).unwrap();
assert_eq!(back, r);
}
#[test]
fn get_request_carries_reference_only() {
let r = req(
PluginRequest::Get(GetParams {
reference: "secret/data/team/jira".into(),
}),
42,
);
let line = serde_json::to_string(&r).unwrap();
assert!(line.contains("\"method\":\"secret_source.get\""));
assert!(line.contains("\"reference\":\"secret/data/team/jira\""));
let back: PluginRpcRequest = serde_json::from_str(&line).unwrap();
assert_eq!(back, r);
}
#[test]
fn list_request_has_no_params() {
let r = req(PluginRequest::List, 99);
let line = serde_json::to_string(&r).unwrap();
assert!(line.contains("\"method\":\"secret_source.list\""));
let back: PluginRpcRequest = serde_json::from_str(&line).unwrap();
assert_eq!(back, r);
}
#[test]
fn validate_request_round_trips() {
let r = req(
PluginRequest::Validate(ValidateParams {
reference: "op://Private/jira".into(),
}),
123,
);
let line = serde_json::to_string(&r).unwrap();
assert!(line.contains("\"method\":\"secret_source.validate\""));
let back: PluginRpcRequest = serde_json::from_str(&line).unwrap();
assert_eq!(back, r);
}
#[test]
fn init_result_round_trips() {
let resp = ok(
1,
PluginResponse::Init(InitResult {
source_name: "prod-vault".into(),
capabilities_bits: 0b0000_0011,
plugin_version: "0.1.0".into(),
}),
);
let line = serde_json::to_string(&resp).unwrap();
let back: PluginRpcResponse = serde_json::from_str(&line).unwrap();
assert_eq!(back, resp);
}
#[test]
fn get_result_round_trips_with_lease() {
let resp = ok(
42,
PluginResponse::Get(GetResult {
value: "test-value-not-secret".into(),
lease_seconds: Some(3600),
}),
);
let line = serde_json::to_string(&resp).unwrap();
let back: PluginRpcResponse = serde_json::from_str(&line).unwrap();
assert_eq!(back, resp);
}
#[test]
fn list_result_round_trips_empty_and_populated() {
let resp = ok(99, PluginResponse::List(ListResult { entries: vec![] }));
let line = serde_json::to_string(&resp).unwrap();
let back: PluginRpcResponse = serde_json::from_str(&line).unwrap();
assert_eq!(back, resp);
let resp2 = ok(
100,
PluginResponse::List(ListResult {
entries: vec![RemoteRefDto {
reference: "secret/data/team/jira".into(),
display: Some("Jira API token".into()),
}],
}),
);
let line2 = serde_json::to_string(&resp2).unwrap();
let back2: PluginRpcResponse = serde_json::from_str(&line2).unwrap();
assert_eq!(back2, resp2);
}
#[test]
fn is_available_status_strings_are_pinned() {
assert_eq!(
serde_json::to_value(IsAvailableStatus::Available).unwrap(),
json!("available")
);
assert_eq!(
serde_json::to_value(IsAvailableStatus::NeedsCredential).unwrap(),
json!("needs-credential")
);
assert_eq!(
serde_json::to_value(IsAvailableStatus::Unavailable).unwrap(),
json!("unavailable")
);
}
#[test]
fn error_response_round_trips_each_kind() {
for err in [
PluginError::Unavailable {
detail: "vault sealed".into(),
},
PluginError::UnsupportedCapability {
capability: "list".into(),
},
PluginError::BadReference {
reference: "garbage".into(),
reason: "not a vault path".into(),
},
PluginError::NeedsCredential {
detail: "op signin required".into(),
},
PluginError::Other {
detail: "transport timeout".into(),
},
] {
let envelope = PluginRpcResponse {
jsonrpc: JsonRpcVersion::current(),
id: 1,
outcome: RpcOutcome::Error(err),
};
let line = serde_json::to_string(&envelope).unwrap();
let back: PluginRpcResponse = serde_json::from_str(&line).unwrap();
assert_eq!(back, envelope);
}
}
#[test]
fn rpc_outcome_distinguishes_result_from_error_at_parse_time() {
let line_ok = r#"{"jsonrpc":"2.0","id":1,"result":{"value":"v","lease_seconds":null}}"#;
let parsed: PluginRpcResponse = serde_json::from_str(line_ok).unwrap();
assert!(matches!(parsed.outcome, RpcOutcome::Result(_)));
let line_err = r#"{"jsonrpc":"2.0","id":1,"error":{"kind":"unavailable","detail":"x"}}"#;
let parsed: PluginRpcResponse = serde_json::from_str(line_err).unwrap();
assert!(matches!(parsed.outcome, RpcOutcome::Error(_)));
}
}