use prost_reflect::{FieldDescriptor, Kind, MessageDescriptor};
use serde_json::{Map, Value};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BodyMapping {
None,
Root,
Field(String),
}
impl BodyMapping {
pub fn parse(raw: &str) -> Self {
match raw {
"" => BodyMapping::None,
"*" => BodyMapping::Root,
field => BodyMapping::Field(field.to_string()),
}
}
}
pub fn build_request_json(
input: &MessageDescriptor,
body_mapping: &BodyMapping,
body_json: Value,
path_params: &HashMap<String, String>,
query: &[(String, String)],
) -> Result<Value, String> {
let mut root = match body_mapping {
BodyMapping::None => Value::Object(Map::new()),
BodyMapping::Root => match body_json {
Value::Object(_) => body_json,
Value::Null => Value::Object(Map::new()),
_ => return Err("request body must be a JSON object".to_string()),
},
BodyMapping::Field(field) => {
let mut m = Map::new();
m.insert(field.clone(), body_json);
Value::Object(m)
}
};
for (key, raw) in path_params {
set_field(&mut root, input, key, true, |field| {
coerce(&field.kind(), raw)
});
}
for (key, values) in group_query(query) {
set_field(&mut root, input, &key, false, |field| {
if field.is_list() {
Value::Array(values.iter().map(|v| coerce(&field.kind(), v)).collect())
} else {
coerce(&field.kind(), values.last().expect("group is non-empty"))
}
});
}
Ok(root)
}
pub fn parse_query(raw: Option<&str>) -> Result<Vec<(String, String)>, String> {
match raw {
None | Some("") => Ok(Vec::new()),
Some(q) => serde_urlencoded::from_str(q).map_err(|e| format!("invalid query string: {e}")),
}
}
pub fn extract_response_body(value: &Value, path: &str) -> Option<Value> {
let mut cur = value;
for seg in path.split('.') {
cur = cur.get(seg)?;
}
Some(cur.clone())
}
fn group_query(query: &[(String, String)]) -> Vec<(String, Vec<String>)> {
let mut grouped: Vec<(String, Vec<String>)> = Vec::new();
for (k, v) in query {
if let Some((_, vals)) = grouped.iter_mut().find(|(gk, _)| gk == k) {
vals.push(v.clone());
} else {
grouped.push((k.clone(), vec![v.clone()]));
}
}
grouped
}
fn set_field<F>(root: &mut Value, input: &MessageDescriptor, dotted: &str, overwrite: bool, make: F)
where
F: FnOnce(&FieldDescriptor) -> Value,
{
let segments: Vec<&str> = dotted.split('.').collect();
let mut desc = input.clone();
let mut cur = root;
for seg in &segments[..segments.len() - 1] {
let Some(field) = desc.get_field_by_name(seg) else {
return;
};
let Kind::Message(message) = field.kind() else {
return;
};
desc = message;
let Some(obj) = cur.as_object_mut() else {
return;
};
cur = obj
.entry((*seg).to_string())
.or_insert_with(|| Value::Object(Map::new()));
}
let leaf = segments[segments.len() - 1];
let Some(field) = desc.get_field_by_name(leaf) else {
return;
};
let Some(obj) = cur.as_object_mut() else {
return;
};
if !overwrite && obj.contains_key(leaf) {
return;
}
obj.insert(leaf.to_string(), make(&field));
}
fn coerce(kind: &Kind, raw: &str) -> Value {
match kind {
Kind::Bool => raw
.parse::<bool>()
.map(Value::Bool)
.unwrap_or_else(|_| Value::String(raw.to_string())),
Kind::Int32 | Kind::Sint32 | Kind::Sfixed32 => raw
.parse::<i32>()
.map(|n| Value::Number(n.into()))
.unwrap_or_else(|_| Value::String(raw.to_string())),
Kind::Uint32 | Kind::Fixed32 => raw
.parse::<u32>()
.map(|n| Value::Number(n.into()))
.unwrap_or_else(|_| Value::String(raw.to_string())),
Kind::Double | Kind::Float => raw
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map(Value::Number)
.unwrap_or_else(|| Value::String(raw.to_string())),
_ => Value::String(raw.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use prost_reflect::prost::Message;
use prost_reflect::prost_types::{
field_descriptor_proto::{Label, Type},
DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet,
};
use prost_reflect::DescriptorPool;
fn field(
name: &str,
num: i32,
ty: Type,
label: Label,
type_name: Option<&str>,
) -> FieldDescriptorProto {
FieldDescriptorProto {
name: Some(name.to_string()),
number: Some(num),
label: Some(label as i32),
r#type: Some(ty as i32),
type_name: type_name.map(|s| s.to_string()),
..Default::default()
}
}
fn test_msg() -> MessageDescriptor {
let nested = DescriptorProto {
name: Some("Nested".to_string()),
field: vec![field("city", 1, Type::String, Label::Optional, None)],
..Default::default()
};
let msg = DescriptorProto {
name: Some("TestMsg".to_string()),
field: vec![
field("name", 1, Type::String, Label::Optional, None),
field("age", 2, Type::Int32, Label::Optional, None),
field("active", 3, Type::Bool, Label::Optional, None),
field("tags", 4, Type::String, Label::Repeated, None),
field("count", 5, Type::Int64, Label::Optional, None),
field(
"nested",
6,
Type::Message,
Label::Optional,
Some(".test.TestMsg.Nested"),
),
],
nested_type: vec![nested],
..Default::default()
};
let file = FileDescriptorProto {
name: Some("test.proto".to_string()),
package: Some("test".to_string()),
message_type: vec![msg],
syntax: Some("proto3".to_string()),
..Default::default()
};
let fds = FileDescriptorSet { file: vec![file] };
let pool = DescriptorPool::decode(fds.encode_to_vec().as_slice()).unwrap();
pool.get_message_by_name("test.TestMsg").unwrap()
}
fn pp(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn qq(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn coerce_unsigned_32_rejects_out_of_range() {
assert_eq!(coerce(&Kind::Uint32, "-1"), Value::String("-1".into()));
assert_eq!(
coerce(&Kind::Uint32, "4294967296"),
Value::String("4294967296".into())
);
assert_eq!(coerce(&Kind::Uint32, "42"), Value::Number(42.into()));
assert_eq!(coerce(&Kind::Fixed32, "-1"), Value::String("-1".into()));
assert_eq!(coerce(&Kind::Int32, "-5"), Value::Number((-5).into()));
assert_eq!(
coerce(&Kind::Int32, "2147483648"),
Value::String("2147483648".into())
);
}
#[test]
fn body_mapping_parse() {
assert_eq!(BodyMapping::parse(""), BodyMapping::None);
assert_eq!(BodyMapping::parse("*"), BodyMapping::Root);
assert_eq!(
BodyMapping::parse("resource"),
BodyMapping::Field("resource".into())
);
}
#[test]
fn body_root_merges_path_and_query() {
let m = test_msg();
let body = serde_json::json!({ "name": "alice" });
let out = build_request_json(
&m,
&BodyMapping::Root,
body,
&pp(&[("age", "30")]),
&qq(&[("active", "true")]),
)
.unwrap();
assert_eq!(out["name"], "alice");
assert_eq!(out["age"], 30); assert_eq!(out["active"], true); }
#[test]
fn body_field_nests_body_under_named_field() {
let m = test_msg();
let body = serde_json::json!({ "city": "berlin" });
let out = build_request_json(
&m,
&BodyMapping::Field("nested".into()),
body,
&pp(&[]),
&qq(&[("name", "bob")]),
)
.unwrap();
assert_eq!(out["nested"]["city"], "berlin");
assert_eq!(out["name"], "bob");
}
#[test]
fn query_repeated_field_becomes_array() {
let m = test_msg();
let out = build_request_json(
&m,
&BodyMapping::None,
Value::Null,
&pp(&[]),
&qq(&[("tags", "a"), ("tags", "b")]),
)
.unwrap();
assert_eq!(out["tags"], serde_json::json!(["a", "b"]));
}
#[test]
fn query_dotted_path_sets_nested_field() {
let m = test_msg();
let out = build_request_json(
&m,
&BodyMapping::None,
Value::Null,
&pp(&[]),
&qq(&[("nested.city", "paris")]),
)
.unwrap();
assert_eq!(out["nested"]["city"], "paris");
}
#[test]
fn query_does_not_override_body_or_path() {
let m = test_msg();
let body = serde_json::json!({ "name": "from_body" });
let out = build_request_json(
&m,
&BodyMapping::Root,
body,
&pp(&[("age", "7")]),
&qq(&[("name", "from_query"), ("age", "99")]),
)
.unwrap();
assert_eq!(out["name"], "from_body"); assert_eq!(out["age"], 7); }
#[test]
fn int64_field_stays_string() {
let m = test_msg();
let out = build_request_json(
&m,
&BodyMapping::None,
Value::Null,
&pp(&[]),
&qq(&[("count", "9007199254740993")]),
)
.unwrap();
assert_eq!(out["count"], "9007199254740993");
}
#[test]
fn unknown_query_field_is_dropped() {
let m = test_msg();
let out = build_request_json(
&m,
&BodyMapping::None,
Value::Null,
&pp(&[]),
&qq(&[("does_not_exist", "x")]),
)
.unwrap();
assert_eq!(out.get("does_not_exist"), None);
}
#[test]
fn root_body_must_be_object() {
let m = test_msg();
let err = build_request_json(
&m,
&BodyMapping::Root,
serde_json::json!("a string"),
&pp(&[]),
&qq(&[]),
);
assert!(err.is_err());
}
#[test]
fn extract_response_body_walks_dotted_path() {
let v = serde_json::json!({ "result": { "token": "abc" } });
assert_eq!(
extract_response_body(&v, "result.token"),
Some(serde_json::json!("abc"))
);
assert_eq!(
extract_response_body(&v, "result"),
Some(serde_json::json!({ "token": "abc" }))
);
assert_eq!(extract_response_body(&v, "missing"), None);
}
#[test]
fn parse_query_handles_empty_and_pairs() {
assert_eq!(parse_query(None).unwrap(), Vec::<(String, String)>::new());
assert_eq!(
parse_query(Some("")).unwrap(),
Vec::<(String, String)>::new()
);
assert_eq!(
parse_query(Some("a=1&b=2")).unwrap(),
vec![("a".into(), "1".into()), ("b".into(), "2".into())]
);
}
}