Skip to main content

synapse_application/
normalize.rs

1use 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/// Canonical operation request produced from one legacy Flux or Scout input.
9#[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    /// Returns the resolved canonical operation.
20    #[must_use]
21    pub fn operation(&self) -> &OperationName {
22        &self.operation
23    }
24
25    /// Returns schema-validated canonical parameters.
26    #[must_use]
27    pub fn parameters(&self) -> &Value {
28        &self.parameters
29    }
30
31    /// Returns requested legacy presentation.
32    #[must_use]
33    pub const fn presentation(&self) -> LegacyPresentation {
34        self.presentation
35    }
36
37    /// Returns the product authorization scope required by the legacy route.
38    #[must_use]
39    pub fn required_scope(&self) -> Option<&str> {
40        self.required_scope.as_deref()
41    }
42
43    /// Returns the historical operation name.
44    #[must_use]
45    pub fn legacy_name(&self) -> &str {
46        &self.legacy_name
47    }
48}
49
50impl SynapseCatalog {
51    /// Normalizes a legacy Flux or Scout input into canonical parameters.
52    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", &parameters)?;
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    /// Validates already canonical parameters for one operation.
111    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;