synapse_application/
normalize.rs1use serde_json::{Map, Value};
2use soma_ops::OperationName;
3
4use crate::{CompatibilityError, LegacyPresentation, LegacyTool, SynapseCatalog};
5
6const SURFACE_FIELDS: [&str; 4] = ["action", "subaction", "response_format", "format"];
7
8#[derive(Debug, Clone, PartialEq)]
10pub struct NormalizedOperationRequest {
11 operation: OperationName,
12 parameters: Value,
13 presentation: LegacyPresentation,
14 required_scope: Option<String>,
15 legacy_name: String,
16}
17
18impl NormalizedOperationRequest {
19 #[must_use]
21 pub fn operation(&self) -> &OperationName {
22 &self.operation
23 }
24
25 #[must_use]
27 pub fn parameters(&self) -> &Value {
28 &self.parameters
29 }
30
31 #[must_use]
33 pub const fn presentation(&self) -> LegacyPresentation {
34 self.presentation
35 }
36
37 #[must_use]
39 pub fn required_scope(&self) -> Option<&str> {
40 self.required_scope.as_deref()
41 }
42
43 #[must_use]
45 pub fn legacy_name(&self) -> &str {
46 &self.legacy_name
47 }
48}
49
50impl SynapseCatalog {
51 pub fn normalize_legacy_request(
53 &self,
54 tool: LegacyTool,
55 input: &Value,
56 ) -> Result<NormalizedOperationRequest, CompatibilityError> {
57 let object = input
58 .as_object()
59 .ok_or_else(|| CompatibilityError::InvalidLegacyRequest {
60 field: "$".into(),
61 message: "expected an object".into(),
62 })?;
63 let action = required_string(object, "action")?;
64 let subaction = optional_string(object, "subaction")?;
65 let binding = self.binding(tool, action, subaction).ok_or_else(|| {
66 CompatibilityError::UnknownLegacyOperation {
67 tool: tool.as_str(),
68 action: action.to_owned(),
69 subaction: subaction.map(str::to_owned),
70 }
71 })?;
72 let operation = binding.canonical_name().clone();
73 let contract = self
74 .parameter_schema(&operation)
75 .ok_or_else(|| CompatibilityError::UnknownOperation(operation.clone()))?;
76 let properties = contract
77 .schema()
78 .get("properties")
79 .and_then(Value::as_object)
80 .ok_or_else(|| CompatibilityError::EmbeddedContract {
81 artifact: "synapse-operation-parameters.json",
82 message: format!("{operation} has no properties object"),
83 })?;
84
85 let mut parameters = Map::new();
86 for (field, value) in object {
87 if SURFACE_FIELDS.contains(&field.as_str()) {
88 continue;
89 }
90 if !properties.contains_key(field) {
91 return Err(CompatibilityError::UnknownField {
92 operation,
93 field: field.clone(),
94 });
95 }
96 parameters.insert(field.clone(), value.clone());
97 }
98 let parameters = Value::Object(parameters);
99 contract.validate(&operation, "parameter", ¶meters)?;
100
101 Ok(NormalizedOperationRequest {
102 operation,
103 parameters,
104 presentation: presentation(object)?,
105 required_scope: binding.scope().map(str::to_owned),
106 legacy_name: binding.legacy_name().to_owned(),
107 })
108 }
109
110 pub fn validate_parameters(
112 &self,
113 operation: &OperationName,
114 parameters: &Value,
115 ) -> Result<(), CompatibilityError> {
116 self.parameter_schema(operation)
117 .ok_or_else(|| CompatibilityError::UnknownOperation(operation.clone()))?
118 .validate(operation, "parameter", parameters)
119 }
120}
121
122fn required_string<'a>(
123 object: &'a Map<String, Value>,
124 field: &str,
125) -> Result<&'a str, CompatibilityError> {
126 object
127 .get(field)
128 .and_then(Value::as_str)
129 .filter(|value| !value.is_empty())
130 .ok_or_else(|| CompatibilityError::InvalidLegacyRequest {
131 field: field.to_owned(),
132 message: "expected a non-empty string".into(),
133 })
134}
135
136fn optional_string<'a>(
137 object: &'a Map<String, Value>,
138 field: &str,
139) -> Result<Option<&'a str>, CompatibilityError> {
140 match object.get(field) {
141 None | Some(Value::Null) => Ok(None),
142 Some(Value::String(value)) if !value.is_empty() => Ok(Some(value)),
143 Some(_) => Err(CompatibilityError::InvalidLegacyRequest {
144 field: field.to_owned(),
145 message: "expected a non-empty string".into(),
146 }),
147 }
148}
149
150fn presentation(object: &Map<String, Value>) -> Result<LegacyPresentation, CompatibilityError> {
151 let format = optional_string(object, "format")?;
152 let response = optional_string(object, "response_format")?;
153 if let (Some(format), Some(response)) = (format, response)
154 && format != response
155 {
156 return Err(CompatibilityError::ConflictingPresentation);
157 }
158 match response.or(format).unwrap_or("markdown") {
159 "markdown" => Ok(LegacyPresentation::Markdown),
160 "json" => Ok(LegacyPresentation::Json),
161 other => Err(CompatibilityError::InvalidPresentation(other.to_owned())),
162 }
163}
164
165#[cfg(test)]
166#[path = "normalize_tests.rs"]
167mod tests;