Skip to main content

grapheme_stdlib/
envelope.rs

1//! Normalized host capability result envelope for Grapheme 0.6.0+.
2//!
3//! Target shape: `{ data, meta, error }` with dual-read compatibility for legacy
4//! flat JSON objects during migration.
5
6use serde_json::{json, Value as JsonValue};
7
8pub const ENVELOPE_SCHEMA: &str = "grapheme.host.result.envelope/v1";
9
10/// Normalize a raw module response into the standard host envelope.
11pub fn normalize(raw: JsonValue) -> JsonValue {
12    if is_envelope(&raw) {
13        return raw;
14    }
15
16    let error = raw
17        .get("error")
18        .and_then(|v| v.as_str())
19        .map(ToOwned::to_owned);
20
21    if error.is_some() {
22        return json!({
23            "data": raw.get("data").cloned().unwrap_or(JsonValue::Null),
24            "meta": merge_meta(raw.get("meta"), json!({ "legacy_flat": true })),
25            "error": error,
26        });
27    }
28
29    json!({
30        "data": raw,
31        "meta": json!({ "legacy_flat": true }),
32        "error": null,
33    })
34}
35
36/// Build a success envelope.
37pub fn success(data: JsonValue) -> JsonValue {
38    json!({
39        "data": data,
40        "meta": json!({ "schema": ENVELOPE_SCHEMA }),
41        "error": null,
42    })
43}
44
45/// Build a failure envelope.
46pub fn failure(error: impl Into<String>) -> JsonValue {
47    json!({
48        "data": null,
49        "meta": json!({ "schema": ENVELOPE_SCHEMA }),
50        "error": error.into(),
51    })
52}
53
54/// Read the primary payload from either envelope or legacy flat JSON.
55pub fn data<'a>(value: &'a JsonValue) -> &'a JsonValue {
56    if is_envelope(value) {
57        return value.get("data").unwrap_or(value);
58    }
59    value
60}
61
62pub fn is_envelope(value: &JsonValue) -> bool {
63    value.as_object().is_some_and(|obj| {
64        obj.contains_key("data") && obj.contains_key("meta") && obj.contains_key("error")
65    })
66}
67
68fn merge_meta(existing: Option<&JsonValue>, extra: JsonValue) -> JsonValue {
69    let Some(JsonValue::Object(map)) = existing else {
70        return extra;
71    };
72
73    let Some(extra_map) = extra.as_object() else {
74        return JsonValue::Object(map.clone());
75    };
76
77    let mut merged = map.clone();
78    for (key, value) in extra_map.clone() {
79        merged.entry(key).or_insert(value);
80    }
81    JsonValue::Object(merged)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use serde_json::json;
88
89    #[test]
90    fn wraps_legacy_flat_object() {
91        let out = normalize(json!({ "accepted": true, "count": 3 }));
92        assert_eq!(out.get("data").and_then(|v| v.get("count")).and_then(|v| v.as_u64()), Some(3));
93        assert!(out.get("error").and_then(|v| v.as_null()).is_some());
94    }
95
96    #[test]
97    fn preserves_existing_envelope() {
98        let input = json!({ "data": { "ok": true }, "meta": {}, "error": null });
99        assert_eq!(normalize(input.clone()), input);
100    }
101}