Skip to main content

chio_openapi/
extensions.rs

1//! Chio extension field handling for OpenAPI operations.
2//!
3//! OpenAPI operations may include `x-chio-*` extension fields to override
4//! default policy decisions on a per-route basis.
5
6use serde::{Deserialize, Serialize};
7
8/// Sensitivity classification for a route. Used by the guard pipeline to
9/// decide logging level and approval requirements.
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum Sensitivity {
13    /// Publicly available data, no special handling.
14    Public,
15    /// Internal data, logged but not restricted beyond defaults.
16    #[default]
17    Internal,
18    /// Sensitive data, may require additional approval.
19    Sensitive,
20    /// Highly restricted data, always requires approval.
21    Restricted,
22}
23
24/// Parsed `x-chio-*` extension fields from an OpenAPI operation.
25#[derive(Debug, Clone, Default)]
26pub struct ChioExtensions {
27    /// `x-chio-sensitivity` -- data sensitivity classification.
28    pub sensitivity: Option<Sensitivity>,
29    /// `x-chio-side-effects` -- explicit override for whether the operation
30    /// has side effects (overrides the HTTP method default).
31    pub side_effects: Option<bool>,
32    /// `x-chio-approval-required` -- whether human approval is needed.
33    pub approval_required: Option<bool>,
34    /// `x-chio-budget-limit` -- maximum cost in minor currency units that a
35    /// single invocation may charge.
36    pub budget_limit: Option<u64>,
37    /// `x-chio-publish` -- whether to include this operation in the generated
38    /// manifest. Defaults to true if absent.
39    pub publish: Option<bool>,
40}
41
42impl ChioExtensions {
43    /// Extract Chio extension fields from a raw JSON object (the operation
44    /// object as parsed from the OpenAPI spec).
45    pub fn from_operation(obj: &serde_json::Value) -> Self {
46        let map = match obj.as_object() {
47            Some(m) => m,
48            None => return Self::default(),
49        };
50
51        Self {
52            sensitivity: map
53                .get("x-chio-sensitivity")
54                .and_then(|v| v.as_str())
55                .and_then(|s| match s {
56                    "public" => Some(Sensitivity::Public),
57                    "internal" => Some(Sensitivity::Internal),
58                    "sensitive" => Some(Sensitivity::Sensitive),
59                    "restricted" => Some(Sensitivity::Restricted),
60                    _ => None,
61                }),
62            side_effects: map.get("x-chio-side-effects").and_then(|v| v.as_bool()),
63            approval_required: map
64                .get("x-chio-approval-required")
65                .and_then(|v| v.as_bool()),
66            budget_limit: map.get("x-chio-budget-limit").and_then(|v| v.as_u64()),
67            publish: map.get("x-chio-publish").and_then(|v| v.as_bool()),
68        }
69    }
70
71    /// Whether this operation should be included in the generated manifest.
72    /// Returns `true` unless `x-chio-publish` is explicitly set to `false`.
73    pub fn should_publish(&self) -> bool {
74        self.publish.unwrap_or(true)
75    }
76}
77
78#[cfg(test)]
79#[allow(clippy::unwrap_used, clippy::expect_used)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn empty_object() {
85        let val = serde_json::json!({});
86        let ext = ChioExtensions::from_operation(&val);
87        assert!(ext.sensitivity.is_none());
88        assert!(ext.side_effects.is_none());
89        assert!(ext.approval_required.is_none());
90        assert!(ext.budget_limit.is_none());
91        assert!(ext.publish.is_none());
92        assert!(ext.should_publish());
93    }
94
95    #[test]
96    fn all_fields_present() {
97        let val = serde_json::json!({
98            "x-chio-sensitivity": "restricted",
99            "x-chio-side-effects": true,
100            "x-chio-approval-required": true,
101            "x-chio-budget-limit": 5000,
102            "x-chio-publish": false
103        });
104        let ext = ChioExtensions::from_operation(&val);
105        assert_eq!(ext.sensitivity, Some(Sensitivity::Restricted));
106        assert_eq!(ext.side_effects, Some(true));
107        assert_eq!(ext.approval_required, Some(true));
108        assert_eq!(ext.budget_limit, Some(5000));
109        assert_eq!(ext.publish, Some(false));
110        assert!(!ext.should_publish());
111    }
112
113    #[test]
114    fn unknown_sensitivity_ignored() {
115        let val = serde_json::json!({ "x-chio-sensitivity": "unknown" });
116        let ext = ChioExtensions::from_operation(&val);
117        assert!(ext.sensitivity.is_none());
118    }
119
120    #[test]
121    fn non_object_returns_default() {
122        let val = serde_json::json!("not an object");
123        let ext = ChioExtensions::from_operation(&val);
124        assert!(ext.sensitivity.is_none());
125    }
126
127    #[test]
128    fn sensitivity_serde_roundtrip() {
129        let s = Sensitivity::Sensitive;
130        let json = serde_json::to_string(&s).unwrap();
131        assert_eq!(json, "\"sensitive\"");
132        let back: Sensitivity = serde_json::from_str(&json).unwrap();
133        assert_eq!(back, s);
134    }
135}