Skip to main content

sc_command/
envelope.rs

1//! Response envelope `{version, ok, data, error}`.
2
3use serde::de::{self, DeserializeOwned};
4use serde::ser::SerializeMap;
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use serde_json::Value;
7
8use crate::error::OpError;
9use crate::exact::{require_exact_keys, required};
10use crate::{DISCRIMINANT_CODE, ENVELOPE_VERSION};
11
12/// sc-ai-cli response envelope.
13///
14/// Success carries `data` and writes `error` as JSON `null`. Failure carries
15/// [`OpError`] and writes `data` as JSON `null`. Constructors stamp
16/// [`ENVELOPE_VERSION`]. Deserialization keeps the `version` string from the
17/// input. An `ok` flag that disagrees with the other field, or an object that
18/// does not have exactly the four keys, is rejected with
19/// [`DISCRIMINANT_CODE`] in the error.
20#[derive(Debug, Clone, PartialEq)]
21pub enum Envelope<T> {
22    /// `ok` is true and `data` is `T`.
23    Success {
24        /// Contract version read or written for this value.
25        version: String,
26        /// Successful payload. JSON `null` is a valid `T` such as `()` or `None`.
27        data: T,
28    },
29    /// `ok` is false and `error` is the operation error.
30    Failure {
31        /// Contract version read or written for this value.
32        version: String,
33        /// Operation error.
34        error: OpError,
35    },
36}
37
38impl<T> Envelope<T> {
39    /// Builds a success envelope at [`ENVELOPE_VERSION`].
40    #[must_use]
41    pub fn success(data: T) -> Self {
42        Self::Success {
43            version: ENVELOPE_VERSION.to_owned(),
44            data,
45        }
46    }
47
48    /// Builds a failure envelope at [`ENVELOPE_VERSION`].
49    #[must_use]
50    pub fn failure(error: OpError) -> Self {
51        Self::Failure {
52            version: ENVELOPE_VERSION.to_owned(),
53            error,
54        }
55    }
56
57    /// Contract version stored on this value.
58    #[must_use]
59    pub fn version(&self) -> &str {
60        match self {
61            Self::Success { version, .. } | Self::Failure { version, .. } => version,
62        }
63    }
64
65    /// Reports whether this value is the success case.
66    #[must_use]
67    pub fn is_ok(&self) -> bool {
68        matches!(self, Self::Success { .. })
69    }
70
71    /// Returns the payload when this value is the success case.
72    #[must_use]
73    pub fn data(&self) -> Option<&T> {
74        match self {
75            Self::Success { data, .. } => Some(data),
76            Self::Failure { .. } => None,
77        }
78    }
79
80    /// Returns the error when this value is the failure case.
81    #[must_use]
82    pub fn error(&self) -> Option<&OpError> {
83        match self {
84            Self::Failure { error, .. } => Some(error),
85            Self::Success { .. } => None,
86        }
87    }
88
89    /// Consumes the envelope and returns the success payload or the operation error.
90    ///
91    /// `version` is not part of the result. A caller that still needs the
92    /// contract version reads [`version`](Self::version) before calling this
93    /// method.
94    ///
95    /// # Errors
96    ///
97    /// Returns `Err` when the envelope is the failure case. The error is the
98    /// [`OpError`] the envelope carried.
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use sc_command::Envelope;
104    ///
105    /// let envelope = Envelope::success("ready");
106    /// assert_eq!(envelope.into_result(), Ok("ready"));
107    /// ```
108    pub fn into_result(self) -> Result<T, OpError> {
109        match self {
110            Self::Success { data, .. } => Ok(data),
111            Self::Failure { error, .. } => Err(error),
112        }
113    }
114}
115
116impl<T> From<Result<T, OpError>> for Envelope<T> {
117    fn from(result: Result<T, OpError>) -> Self {
118        match result {
119            Ok(data) => Self::success(data),
120            Err(error) => Self::failure(error),
121        }
122    }
123}
124
125impl<T> Serialize for Envelope<T>
126where
127    T: Serialize,
128{
129    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
130    where
131        S: Serializer,
132    {
133        let mut map = serializer.serialize_map(Some(4))?;
134        match self {
135            Self::Success { version, data } => {
136                map.serialize_entry("version", version)?;
137                map.serialize_entry("ok", &true)?;
138                map.serialize_entry("data", data)?;
139                map.serialize_entry("error", &None::<OpError>)?;
140            }
141            Self::Failure { version, error } => {
142                map.serialize_entry("version", version)?;
143                map.serialize_entry("ok", &false)?;
144                map.serialize_entry("data", &None::<()>)?;
145                map.serialize_entry("error", error)?;
146            }
147        }
148        map.end()
149    }
150}
151
152impl<'de, T> Deserialize<'de> for Envelope<T>
153where
154    T: DeserializeOwned,
155{
156    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
157    where
158        D: Deserializer<'de>,
159    {
160        let value = Value::deserialize(deserializer)?;
161        envelope_from_value(value).map_err(de::Error::custom)
162    }
163}
164
165fn envelope_from_value<T>(value: Value) -> Result<Envelope<T>, String>
166where
167    T: DeserializeOwned,
168{
169    let map = match value {
170        Value::Object(map) => map,
171        _ => {
172            return Err(format!(
173                "{DISCRIMINANT_CODE}: envelope must be a JSON object"
174            ));
175        }
176    };
177    require_exact_keys(&map, &["version", "ok", "data", "error"])?;
178    let version = match required(&map, "version")? {
179        Value::String(version) => version.clone(),
180        _ => {
181            return Err(format!(
182                "{DISCRIMINANT_CODE}: version must be a JSON string"
183            ));
184        }
185    };
186    let ok = match required(&map, "ok")? {
187        Value::Bool(ok) => *ok,
188        _ => {
189            return Err(format!("{DISCRIMINANT_CODE}: ok must be a JSON boolean"));
190        }
191    };
192    let data = required(&map, "data")?;
193    let error = required(&map, "error")?;
194    if ok {
195        if !error.is_null() {
196            return Err(format!(
197                "{DISCRIMINANT_CODE}: ok is true and error is not null"
198            ));
199        }
200        let data = T::deserialize(data).map_err(|error| format!("{DISCRIMINANT_CODE}: {error}"))?;
201        Ok(Envelope::Success { version, data })
202    } else {
203        if !data.is_null() {
204            return Err(format!(
205                "{DISCRIMINANT_CODE}: ok is false and data is not null"
206            ));
207        }
208        if error.is_null() {
209            return Err(format!(
210                "{DISCRIMINANT_CODE}: ok is false and error is null"
211            ));
212        }
213        let error =
214            OpError::deserialize(error).map_err(|error| format!("{DISCRIMINANT_CODE}: {error}"))?;
215        Ok(Envelope::Failure { version, error })
216    }
217}
218
219#[cfg(feature = "schema")]
220fn envelope_schema(data: Value, error: Value) -> serde_json::Map<String, Value> {
221    serde_json::Map::from_iter([(
222        "oneOf".into(),
223        serde_json::json!([
224            {"type":"object", "additionalProperties":false,
225             "required":["version","ok","data","error"],
226             "properties":{"version":{"type":"string"},"ok":{"type":"boolean","enum":[true]},"data":data,"error":{"type":"null"}}},
227            {"type":"object", "additionalProperties":false,
228             "required":["version","ok","data","error"],
229             "properties":{"version":{"type":"string"},"ok":{"type":"boolean","enum":[false]},"data":{"type":"null"},"error":error}}
230        ]),
231    )])
232}
233
234#[cfg(feature = "schema")]
235impl<T: schemars::JsonSchema> schemars::JsonSchema for Envelope<T> {
236    fn schema_name() -> std::borrow::Cow<'static, str> {
237        format!("Envelope_{}", T::schema_name()).into()
238    }
239
240    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
241        let value = envelope_schema(
242            generator.subschema_for::<T>().to_value(),
243            generator.subschema_for::<OpError>().to_value(),
244        );
245        schemars::Schema::from(value)
246    }
247}
248
249#[cfg(feature = "schema")]
250impl<T: utoipa::ToSchema> utoipa::__dev::ComposeSchema for Envelope<T> {
251    fn compose(
252        _: Vec<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>,
253    ) -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
254        use utoipa::openapi::schema::{AdditionalProperties, ObjectBuilder, OneOfBuilder, Type};
255        let object = || {
256            ObjectBuilder::new()
257                .schema_type(Type::Object)
258                .additional_properties(Some(AdditionalProperties::FreeForm(false)))
259                .required("version")
260                .required("ok")
261                .required("data")
262                .required("error")
263                .property("version", <String as utoipa::PartialSchema>::schema())
264        };
265        let success = object()
266            .property(
267                "ok",
268                ObjectBuilder::new()
269                    .schema_type(Type::Boolean)
270                    .enum_values(Some([true])),
271            )
272            .property("data", T::schema())
273            .property("error", ObjectBuilder::new().schema_type(Type::Null));
274        let failure = object()
275            .property(
276                "ok",
277                ObjectBuilder::new()
278                    .schema_type(Type::Boolean)
279                    .enum_values(Some([false])),
280            )
281            .property("data", ObjectBuilder::new().schema_type(Type::Null))
282            .property("error", <OpError as utoipa::PartialSchema>::schema());
283        OneOfBuilder::new().item(success).item(failure).into()
284    }
285}
286
287#[cfg(feature = "schema")]
288impl<T: utoipa::ToSchema> utoipa::ToSchema for Envelope<T> {
289    fn name() -> std::borrow::Cow<'static, str> {
290        format!("Envelope_{}", T::name()).into()
291    }
292
293    fn schemas(
294        schemas: &mut Vec<(
295            String,
296            utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
297        )>,
298    ) {
299        schemas.push((T::name().into_owned(), T::schema()));
300        T::schemas(schemas);
301    }
302}
303
304#[cfg(all(test, feature = "schema"))]
305mod typed_projection_schema {
306    use super::Envelope;
307    use utoipa::OpenApi;
308
309    #[derive(utoipa::ToSchema)]
310    struct Body {
311        value: String,
312    }
313
314    #[utoipa::path(post, path = "/typed", responses((status = 200, body = Envelope<Body>)))]
315    fn typed_route() {}
316
317    #[derive(utoipa::OpenApi)]
318    #[openapi(paths(typed_route))]
319    struct Api;
320
321    #[test]
322    fn actual_typed_path_preserves_generic_envelope_payload_schema() {
323        typed_route();
324        let body = Body {
325            value: "typed".into(),
326        };
327        assert_eq!(body.value, "typed");
328        let specification = serde_json::to_value(Api::openapi()).expect("OpenAPI");
329        let response = &specification["paths"]["/typed"]["post"]["responses"]["200"];
330        assert!(response.to_string().contains("Envelope_Body"));
331        let reference = response["content"]["application/json"]["schema"]["$ref"]
332            .as_str()
333            .expect("typed response schema reference");
334        let component = reference
335            .strip_prefix("#/components/schemas/")
336            .expect("component reference");
337        let schema = &specification["components"]["schemas"][component];
338        assert_eq!(
339            schema["oneOf"][0]["properties"]["data"]["properties"]["value"]["type"],
340            "string"
341        );
342        assert_eq!(schema["oneOf"][1]["properties"]["data"]["type"], "null");
343    }
344}