chore(secrets-mcp): 0.5.1 — 移除 entry type 归一化,MCP 参数兼容字符串形式
- 去掉 taxonomy 对 entry type 的自动映射与 metadata.subtype 回填;仅 trim 后入库 - MCP tools:Vec/Map/bool 等可选字段支持 JSON 内嵌字符串解析,并改进解析失败提示 - 新增 deser 单元测试;README/AGENTS 与 models 注释同步 Made-with: Cursor
This commit is contained in:
@@ -4,7 +4,7 @@ use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A top-level entry (server, service, key, person, …).
|
||||
/// A top-level entry (server, service, account, person, …).
|
||||
/// Sensitive fields are stored separately in `secrets`.
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Entry {
|
||||
|
||||
@@ -9,7 +9,6 @@ use crate::crypto;
|
||||
use crate::db;
|
||||
use crate::error::{AppError, DbErrorContext};
|
||||
use crate::models::EntryRow;
|
||||
use crate::taxonomy;
|
||||
|
||||
// ── Key/value parsing helpers ─────────────────────────────────────────────────
|
||||
|
||||
@@ -186,11 +185,10 @@ pub struct AddParams<'a> {
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> Result<AddResult> {
|
||||
let Value::Object(mut metadata_map) = build_json(params.meta_entries)? else {
|
||||
let Value::Object(metadata_map) = build_json(params.meta_entries)? else {
|
||||
unreachable!("build_json always returns a JSON object");
|
||||
};
|
||||
let normalized_entry_type =
|
||||
taxonomy::normalize_entry_type_and_metadata(params.entry_type, &mut metadata_map);
|
||||
let entry_type = params.entry_type.trim();
|
||||
let metadata = Value::Object(metadata_map);
|
||||
let secret_json = build_json(params.secret_entries)?;
|
||||
let meta_keys = collect_key_paths(params.meta_entries)?;
|
||||
@@ -232,7 +230,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
entry_id: ex.id,
|
||||
user_id: params.user_id,
|
||||
folder: params.folder,
|
||||
entry_type: &normalized_entry_type,
|
||||
entry_type,
|
||||
name: params.name,
|
||||
version: ex.version,
|
||||
action: "add",
|
||||
@@ -262,7 +260,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(params.folder)
|
||||
.bind(&normalized_entry_type)
|
||||
.bind(entry_type)
|
||||
.bind(params.name)
|
||||
.bind(params.notes)
|
||||
.bind(params.tags)
|
||||
@@ -285,7 +283,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
RETURNING id"#,
|
||||
)
|
||||
.bind(params.folder)
|
||||
.bind(&normalized_entry_type)
|
||||
.bind(entry_type)
|
||||
.bind(params.name)
|
||||
.bind(params.notes)
|
||||
.bind(params.tags)
|
||||
@@ -307,7 +305,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
entry_id,
|
||||
user_id: params.user_id,
|
||||
folder: params.folder,
|
||||
entry_type: &normalized_entry_type,
|
||||
entry_type,
|
||||
name: params.name,
|
||||
version: current_entry_version,
|
||||
action: "create",
|
||||
@@ -434,7 +432,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
params.user_id,
|
||||
"add",
|
||||
params.folder,
|
||||
&normalized_entry_type,
|
||||
entry_type,
|
||||
params.name,
|
||||
serde_json::json!({
|
||||
"tags": params.tags,
|
||||
@@ -449,7 +447,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
Ok(AddResult {
|
||||
name: params.name.to_string(),
|
||||
folder: params.folder.to_string(),
|
||||
entry_type: normalized_entry_type,
|
||||
entry_type: entry_type.to_string(),
|
||||
tags: params.tags.to_vec(),
|
||||
meta_keys,
|
||||
secret_keys,
|
||||
|
||||
@@ -11,7 +11,6 @@ use crate::service::add::{
|
||||
collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
|
||||
parse_kv, remove_path,
|
||||
};
|
||||
use crate::taxonomy;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct UpdateResult {
|
||||
@@ -501,13 +500,7 @@ pub async fn update_fields_by_id(
|
||||
tracing::warn!(error = %e, "failed to snapshot entry history before web update");
|
||||
}
|
||||
|
||||
let mut metadata_map = match params.metadata {
|
||||
Value::Object(m) => m.clone(),
|
||||
_ => Map::new(),
|
||||
};
|
||||
let normalized_type =
|
||||
taxonomy::normalize_entry_type_and_metadata(params.entry_type, &mut metadata_map);
|
||||
let normalized_metadata = Value::Object(metadata_map);
|
||||
let entry_type = params.entry_type.trim();
|
||||
|
||||
let res = sqlx::query(
|
||||
"UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \
|
||||
@@ -515,11 +508,11 @@ pub async fn update_fields_by_id(
|
||||
WHERE id = $7 AND version = $8",
|
||||
)
|
||||
.bind(params.folder)
|
||||
.bind(&normalized_type)
|
||||
.bind(entry_type)
|
||||
.bind(params.name)
|
||||
.bind(params.notes)
|
||||
.bind(params.tags)
|
||||
.bind(&normalized_metadata)
|
||||
.bind(params.metadata)
|
||||
.bind(row.id)
|
||||
.bind(row.version)
|
||||
.execute(&mut *tx)
|
||||
@@ -546,7 +539,7 @@ pub async fn update_fields_by_id(
|
||||
Some(user_id),
|
||||
"update",
|
||||
params.folder,
|
||||
&normalized_type,
|
||||
entry_type,
|
||||
params.name,
|
||||
serde_json::json!({
|
||||
"source": "web",
|
||||
|
||||
@@ -1,111 +1,4 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
fn normalize_token(input: &str) -> String {
|
||||
input.trim().to_lowercase().replace('_', "-")
|
||||
}
|
||||
|
||||
fn normalize_subtype_token(input: &str) -> String {
|
||||
normalize_token(input)
|
||||
}
|
||||
|
||||
fn map_legacy_entry_type(input: &str) -> Option<(&'static str, &'static str)> {
|
||||
match input {
|
||||
"log-ingestion-endpoint" => Some(("service", "log-ingestion")),
|
||||
"cloud-api" => Some(("service", "cloud-api")),
|
||||
"git-server" => Some(("service", "git")),
|
||||
"mqtt-broker" => Some(("service", "mqtt-broker")),
|
||||
"database" => Some(("service", "database")),
|
||||
"monitoring-dashboard" => Some(("service", "monitoring")),
|
||||
"dns-api" => Some(("service", "dns-api")),
|
||||
"notification-webhook" => Some(("service", "webhook")),
|
||||
"api-endpoint" => Some(("service", "api-endpoint")),
|
||||
"credential" | "credential-key" => Some(("service", "credential")),
|
||||
"key" => Some(("service", "credential")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize entry `type` and optionally backfill `metadata.subtype` for legacy values.
|
||||
///
|
||||
/// This keeps backward compatibility:
|
||||
/// - stable primary types stay unchanged
|
||||
/// - known legacy long-tail types are mapped to `service` + `metadata.subtype`
|
||||
/// - unknown values are kept (normalized to kebab-case) instead of hard failing
|
||||
pub fn normalize_entry_type_and_metadata(
|
||||
entry_type: &str,
|
||||
metadata: &mut Map<String, Value>,
|
||||
) -> String {
|
||||
let original_raw = entry_type.trim();
|
||||
let normalized = normalize_token(original_raw);
|
||||
if normalized.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if let Some((mapped_type, mapped_subtype)) = map_legacy_entry_type(&normalized) {
|
||||
if !metadata.contains_key("subtype") {
|
||||
metadata.insert(
|
||||
"subtype".to_string(),
|
||||
Value::String(mapped_subtype.to_string()),
|
||||
);
|
||||
}
|
||||
if !metadata.contains_key("_original_type") && original_raw != mapped_type {
|
||||
metadata.insert(
|
||||
"_original_type".to_string(),
|
||||
Value::String(original_raw.to_string()),
|
||||
);
|
||||
}
|
||||
return mapped_type.to_string();
|
||||
}
|
||||
|
||||
if let Some(subtype) = metadata.get_mut("subtype")
|
||||
&& let Some(s) = subtype.as_str()
|
||||
{
|
||||
*subtype = Value::String(normalize_subtype_token(s));
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
/// Canonical secret type options for UI dropdowns.
|
||||
pub const SECRET_TYPE_OPTIONS: &[&str] = &[
|
||||
"text", "password", "token", "api-key", "ssh-key", "url", "phone", "id-card",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[test]
|
||||
fn normalize_entry_type_maps_legacy_type_and_backfills_metadata() {
|
||||
let mut metadata = Map::new();
|
||||
let normalized = normalize_entry_type_and_metadata("git-server", &mut metadata);
|
||||
|
||||
assert_eq!(normalized, "service");
|
||||
assert_eq!(
|
||||
metadata.get("subtype"),
|
||||
Some(&Value::String("git".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.get("_original_type"),
|
||||
Some(&Value::String("git-server".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_entry_type_normalizes_existing_subtype() {
|
||||
let mut metadata = Map::new();
|
||||
metadata.insert(
|
||||
"subtype".to_string(),
|
||||
Value::String("Cloud_API".to_string()),
|
||||
);
|
||||
|
||||
let normalized = normalize_entry_type_and_metadata("service", &mut metadata);
|
||||
|
||||
assert_eq!(normalized, "service");
|
||||
assert_eq!(
|
||||
metadata.get("subtype"),
|
||||
Some(&Value::String("cloud-api".to_string()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "secrets-mcp"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -70,6 +70,94 @@ mod deser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize a bool that may come as a JSON bool or a JSON string ("true"/"false").
|
||||
pub fn option_bool_from_string<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum BoolOrStr {
|
||||
Bool(bool),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
match Option::<BoolOrStr>::deserialize(deserializer)? {
|
||||
None => Ok(None),
|
||||
Some(BoolOrStr::Bool(b)) => Ok(Some(b)),
|
||||
Some(BoolOrStr::Str(s)) => {
|
||||
if s.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
s.parse::<bool>().map(Some).map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize a Vec<String> that may come as a JSON array or a JSON string containing an array.
|
||||
pub fn option_vec_string_from_string<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Vec<String>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum VecOrStr {
|
||||
Vec(Vec<String>),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
match Option::<VecOrStr>::deserialize(deserializer)? {
|
||||
None => Ok(None),
|
||||
Some(VecOrStr::Vec(v)) => Ok(Some(v)),
|
||||
Some(VecOrStr::Str(s)) => {
|
||||
if s.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
serde_json::from_str(&s)
|
||||
.map(Some)
|
||||
.map_err(|e| {
|
||||
de::Error::custom(format!(
|
||||
"invalid string value for array field: expected a JSON array, e.g. '[\"a\",\"b\"]': {e}"
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize a Map<String, Value> that may come as a JSON object or a JSON string containing an object.
|
||||
pub fn option_map_from_string<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Map<String, Value>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum MapOrStr {
|
||||
Map(Map<String, Value>),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
match Option::<MapOrStr>::deserialize(deserializer)? {
|
||||
None => Ok(None),
|
||||
Some(MapOrStr::Map(m)) => Ok(Some(m)),
|
||||
Some(MapOrStr::Str(s)) => {
|
||||
if s.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
serde_json::from_str(&s)
|
||||
.map(Some)
|
||||
.map_err(|e| {
|
||||
de::Error::custom(format!(
|
||||
"invalid string value for object field: expected a JSON object, e.g. '{{\"key\":\"value\"}}': {e}"
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use secrets_core::models::ExportFormat;
|
||||
@@ -229,7 +317,7 @@ struct FindInput {
|
||||
#[schemars(description = "Exact folder filter (e.g. 'refining', 'ricnsmart')")]
|
||||
folder: Option<String>,
|
||||
#[schemars(
|
||||
description = "Exact type filter (recommended: 'server', 'service', 'person', 'document')"
|
||||
description = "Exact type filter (e.g. 'server', 'service', 'account', 'person', 'document'). User-defined, any value accepted."
|
||||
)]
|
||||
#[serde(rename = "type")]
|
||||
entry_type: Option<String>,
|
||||
@@ -240,6 +328,7 @@ struct FindInput {
|
||||
)]
|
||||
name_query: Option<String>,
|
||||
#[schemars(description = "Tag filters (all must match)")]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
tags: Option<Vec<String>>,
|
||||
#[schemars(description = "Max results (default 20)")]
|
||||
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
|
||||
@@ -253,7 +342,7 @@ struct SearchInput {
|
||||
#[schemars(description = "Folder filter (e.g. 'refining', 'personal', 'family')")]
|
||||
folder: Option<String>,
|
||||
#[schemars(
|
||||
description = "Type filter (recommended: 'server', 'service', 'person', 'document')"
|
||||
description = "Type filter (e.g. 'server', 'service', 'account', 'person', 'document'). User-defined, any value accepted."
|
||||
)]
|
||||
#[serde(rename = "type")]
|
||||
entry_type: Option<String>,
|
||||
@@ -264,8 +353,10 @@ struct SearchInput {
|
||||
)]
|
||||
name_query: Option<String>,
|
||||
#[schemars(description = "Tag filters (all must match)")]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
tags: Option<Vec<String>>,
|
||||
#[schemars(description = "Return only summary fields (name/tags/notes/updated_at)")]
|
||||
#[serde(default, deserialize_with = "deser::option_bool_from_string")]
|
||||
summary: Option<bool>,
|
||||
#[schemars(description = "Sort order: 'name' (default), 'updated', 'created'")]
|
||||
sort: Option<String>,
|
||||
@@ -292,35 +383,42 @@ struct AddInput {
|
||||
#[schemars(description = "Folder for organization (optional, e.g. 'personal', 'refining')")]
|
||||
folder: Option<String>,
|
||||
#[schemars(
|
||||
description = "Type/category of this entry (optional, recommended: 'server', 'service', 'person', 'document')"
|
||||
description = "Type/category of this entry (optional, e.g. 'server', 'service', 'account', 'person', 'document'). Free-form, choose what best describes the entry."
|
||||
)]
|
||||
#[serde(rename = "type")]
|
||||
entry_type: Option<String>,
|
||||
#[schemars(description = "Free-text notes for this entry (optional)")]
|
||||
notes: Option<String>,
|
||||
#[schemars(description = "Tags for this entry")]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
tags: Option<Vec<String>>,
|
||||
#[schemars(description = "Metadata fields as 'key=value' or 'key:=json' strings")]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
meta: Option<Vec<String>>,
|
||||
#[schemars(
|
||||
description = "Metadata fields as a JSON object {\"key\": value}. Merged with 'meta' if both provided."
|
||||
)]
|
||||
#[serde(default, deserialize_with = "deser::option_map_from_string")]
|
||||
meta_obj: Option<Map<String, Value>>,
|
||||
#[schemars(
|
||||
description = "Secret fields as 'key=value' strings. Reminder: non-sensitive endpoint/address fields should go to metadata.address instead of secrets."
|
||||
)]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
secrets: Option<Vec<String>>,
|
||||
#[schemars(
|
||||
description = "Secret fields as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided. Reminder: non-sensitive endpoint/address fields should go to metadata.address."
|
||||
)]
|
||||
#[serde(default, deserialize_with = "deser::option_map_from_string")]
|
||||
secrets_obj: Option<Map<String, Value>>,
|
||||
#[schemars(
|
||||
description = "Secret types as {\"secret_name\": \"type\"}. Keys must match secret field names. Missing keys default to \"text\"."
|
||||
)]
|
||||
#[serde(default, deserialize_with = "deser::option_map_from_string")]
|
||||
secret_types: Option<Map<String, Value>>,
|
||||
#[schemars(
|
||||
description = "Link existing secrets by secret name. Names must resolve uniquely under current user."
|
||||
)]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
link_secret_names: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -339,38 +437,49 @@ struct UpdateInput {
|
||||
#[schemars(description = "Update the notes field")]
|
||||
notes: Option<String>,
|
||||
#[schemars(description = "Tags to add")]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
add_tags: Option<Vec<String>>,
|
||||
#[schemars(description = "Tags to remove")]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
remove_tags: Option<Vec<String>>,
|
||||
#[schemars(description = "Metadata fields to update/add as 'key=value' strings")]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
meta: Option<Vec<String>>,
|
||||
#[schemars(
|
||||
description = "Metadata fields to update/add as a JSON object {\"key\": value}. Merged with 'meta' if both provided."
|
||||
)]
|
||||
#[serde(default, deserialize_with = "deser::option_map_from_string")]
|
||||
meta_obj: Option<Map<String, Value>>,
|
||||
#[schemars(description = "Metadata field keys to remove")]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
remove_meta: Option<Vec<String>>,
|
||||
#[schemars(
|
||||
description = "Secret fields to update/add as 'key=value' strings. Reminder: non-sensitive endpoint/address fields should go to metadata.address instead of secrets."
|
||||
)]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
secrets: Option<Vec<String>>,
|
||||
#[schemars(
|
||||
description = "Secret fields to update/add as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided. Reminder: non-sensitive endpoint/address fields should go to metadata.address."
|
||||
)]
|
||||
#[serde(default, deserialize_with = "deser::option_map_from_string")]
|
||||
secrets_obj: Option<Map<String, Value>>,
|
||||
#[schemars(
|
||||
description = "Secret types as {\"secret_name\": \"type\"}. Keys must match secret field names. Missing keys default to \"text\"."
|
||||
)]
|
||||
#[serde(default, deserialize_with = "deser::option_map_from_string")]
|
||||
secret_types: Option<Map<String, Value>>,
|
||||
#[schemars(description = "Secret field keys to remove")]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
remove_secrets: Option<Vec<String>>,
|
||||
#[schemars(
|
||||
description = "Link existing secrets by name to this entry. Names must resolve uniquely under current user."
|
||||
)]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
link_secret_names: Option<Vec<String>>,
|
||||
#[schemars(
|
||||
description = "Unlink secrets by name from this entry. Orphaned secrets are auto-deleted."
|
||||
)]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
unlink_secret_names: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -390,6 +499,7 @@ struct DeleteInput {
|
||||
#[serde(rename = "type")]
|
||||
entry_type: Option<String>,
|
||||
#[schemars(description = "Preview deletions without writing")]
|
||||
#[serde(default, deserialize_with = "deser::option_bool_from_string")]
|
||||
dry_run: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -437,6 +547,7 @@ struct ExportInput {
|
||||
#[schemars(description = "Exact name filter")]
|
||||
name: Option<String>,
|
||||
#[schemars(description = "Tag filters")]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
tags: Option<Vec<String>>,
|
||||
#[schemars(description = "Fuzzy query")]
|
||||
query: Option<String>,
|
||||
@@ -454,8 +565,10 @@ struct EnvMapInput {
|
||||
#[schemars(description = "Exact name filter")]
|
||||
name: Option<String>,
|
||||
#[schemars(description = "Tag filters")]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
tags: Option<Vec<String>>,
|
||||
#[schemars(description = "Only include these secret fields")]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
only_fields: Option<Vec<String>>,
|
||||
#[schemars(description = "Environment variable name prefix. \
|
||||
Variable names are built as UPPER(prefix)_UPPER(entry_name)_UPPER(field_name), \
|
||||
@@ -1286,3 +1399,202 @@ impl ServerHandler for SecretsService {
|
||||
info
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod deser_tests {
|
||||
use super::deser;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TestU32 {
|
||||
#[serde(deserialize_with = "deser::option_u32_from_string")]
|
||||
val: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TestI64 {
|
||||
#[serde(deserialize_with = "deser::option_i64_from_string")]
|
||||
val: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TestBool {
|
||||
#[serde(deserialize_with = "deser::option_bool_from_string")]
|
||||
val: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TestVec {
|
||||
#[serde(deserialize_with = "deser::option_vec_string_from_string")]
|
||||
val: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TestMap {
|
||||
#[serde(deserialize_with = "deser::option_map_from_string")]
|
||||
val: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
// option_u32_from_string
|
||||
#[test]
|
||||
fn u32_native_number() {
|
||||
let v: TestU32 = serde_json::from_value(json!({"val": 42})).unwrap();
|
||||
assert_eq!(v.val, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u32_string_number() {
|
||||
let v: TestU32 = serde_json::from_value(json!({"val": "42"})).unwrap();
|
||||
assert_eq!(v.val, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u32_empty_string() {
|
||||
let v: TestU32 = serde_json::from_value(json!({"val": ""})).unwrap();
|
||||
assert_eq!(v.val, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u32_none() {
|
||||
let v: TestU32 = serde_json::from_value(json!({"val": null})).unwrap();
|
||||
assert_eq!(v.val, None);
|
||||
}
|
||||
|
||||
// option_i64_from_string
|
||||
#[test]
|
||||
fn i64_native_number() {
|
||||
let v: TestI64 = serde_json::from_value(json!({"val": -100})).unwrap();
|
||||
assert_eq!(v.val, Some(-100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i64_string_number() {
|
||||
let v: TestI64 = serde_json::from_value(json!({"val": "999"})).unwrap();
|
||||
assert_eq!(v.val, Some(999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i64_empty_string() {
|
||||
let v: TestI64 = serde_json::from_value(json!({"val": ""})).unwrap();
|
||||
assert_eq!(v.val, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i64_none() {
|
||||
let v: TestI64 = serde_json::from_value(json!({"val": null})).unwrap();
|
||||
assert_eq!(v.val, None);
|
||||
}
|
||||
|
||||
// option_bool_from_string
|
||||
#[test]
|
||||
fn bool_native_true() {
|
||||
let v: TestBool = serde_json::from_value(json!({"val": true})).unwrap();
|
||||
assert_eq!(v.val, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bool_native_false() {
|
||||
let v: TestBool = serde_json::from_value(json!({"val": false})).unwrap();
|
||||
assert_eq!(v.val, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bool_string_true() {
|
||||
let v: TestBool = serde_json::from_value(json!({"val": "true"})).unwrap();
|
||||
assert_eq!(v.val, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bool_string_false() {
|
||||
let v: TestBool = serde_json::from_value(json!({"val": "false"})).unwrap();
|
||||
assert_eq!(v.val, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bool_empty_string() {
|
||||
let v: TestBool = serde_json::from_value(json!({"val": ""})).unwrap();
|
||||
assert_eq!(v.val, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bool_none() {
|
||||
let v: TestBool = serde_json::from_value(json!({"val": null})).unwrap();
|
||||
assert_eq!(v.val, None);
|
||||
}
|
||||
|
||||
// option_vec_string_from_string
|
||||
#[test]
|
||||
fn vec_native_array() {
|
||||
let v: TestVec = serde_json::from_value(json!({"val": ["a", "b"]})).unwrap();
|
||||
assert_eq!(v.val, Some(vec!["a".to_string(), "b".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vec_json_string_array() {
|
||||
let v: TestVec = serde_json::from_value(json!({"val": "[\"x\",\"y\"]"})).unwrap();
|
||||
assert_eq!(v.val, Some(vec!["x".to_string(), "y".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vec_empty_string() {
|
||||
let v: TestVec = serde_json::from_value(json!({"val": ""})).unwrap();
|
||||
assert_eq!(v.val, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vec_none() {
|
||||
let v: TestVec = serde_json::from_value(json!({"val": null})).unwrap();
|
||||
assert_eq!(v.val, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vec_invalid_string_errors() {
|
||||
let err = serde_json::from_value::<TestVec>(json!({"val": "not-json"}))
|
||||
.expect_err("should fail on invalid JSON");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("invalid string value for array field"));
|
||||
assert!(msg.contains("expected a JSON array"));
|
||||
}
|
||||
|
||||
// option_map_from_string
|
||||
#[test]
|
||||
fn map_native_object() {
|
||||
let v: TestMap = serde_json::from_value(json!({"val": {"key": "value"}})).unwrap();
|
||||
assert!(v.val.is_some());
|
||||
let m = v.val.unwrap();
|
||||
assert_eq!(
|
||||
m.get("key"),
|
||||
Some(&serde_json::Value::String("value".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_json_string_object() {
|
||||
let v: TestMap = serde_json::from_value(json!({"val": "{\"a\":1}"})).unwrap();
|
||||
assert!(v.val.is_some());
|
||||
let m = v.val.unwrap();
|
||||
assert_eq!(m.get("a"), Some(&serde_json::Value::Number(1.into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_empty_string() {
|
||||
let v: TestMap = serde_json::from_value(json!({"val": ""})).unwrap();
|
||||
assert_eq!(v.val, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_none() {
|
||||
let v: TestMap = serde_json::from_value(json!({"val": null})).unwrap();
|
||||
assert_eq!(v.val, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_invalid_string_errors() {
|
||||
let err = serde_json::from_value::<TestMap>(json!({"val": "not-json"}))
|
||||
.expect_err("should fail on invalid JSON");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("invalid string value for object field"));
|
||||
assert!(msg.contains("expected a JSON object"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user