Skip to main content

stackless_stripe_projects/
responses.rs

1//! Typed envelopes for the `stripe projects` reads the driver depends on,
2//! replacing string-keyed `serde_json::Value` traversal.
3
4use std::collections::BTreeMap;
5
6use serde::Deserialize;
7use serde_json::Value;
8
9/// `stripe projects status --json` data.
10#[derive(Debug, Default, Deserialize)]
11pub struct StatusResponse {
12    #[serde(default)]
13    pub project: Option<ProjectRef>,
14}
15
16impl StatusResponse {
17    /// The linked project id, if any.
18    pub fn project_id(&self) -> Option<&str> {
19        self.project.as_ref().and_then(|p| p.id.as_deref())
20    }
21}
22
23#[derive(Debug, Deserialize)]
24pub struct ProjectRef {
25    #[serde(default)]
26    pub id: Option<String>,
27}
28
29/// One row from a `--preflight` response (`data.preflight` or
30/// `error.details.preflight`).
31#[derive(Debug, Clone, Deserialize)]
32pub struct PreflightCheck {
33    pub label: String,
34    pub pass: bool,
35    #[serde(default)]
36    pub code: Option<String>,
37    #[serde(default)]
38    pub remedy: Option<String>,
39}
40
41/// Successful `--preflight` payload (`data` when `ok: true`).
42#[derive(Debug, Default, Deserialize)]
43pub struct PreflightReady {
44    #[serde(default)]
45    pub preflight: Vec<PreflightCheck>,
46    #[serde(default)]
47    pub ready: Option<bool>,
48}
49
50/// `stripe projects env list --json` data. Tolerates the three shapes the CLI
51/// has emitted: `{environments: {<name>: …}}`, `{environments: [{name}]}`, and a
52/// bare `[{name}]` array. At 0.23.0 the canonical shape is the named map inside
53/// `data.environments`; older bare/list shapes are kept for fixture stability.
54#[derive(Debug, Deserialize)]
55#[serde(untagged)]
56pub enum EnvListResponse {
57    // `Bare` first: serde can deserialize a struct from a sequence, so `Wrapped`
58    // would greedily swallow a bare array if it came first.
59    Bare(Vec<EnvRef>),
60    Wrapped(EnvWrapper),
61}
62
63#[derive(Debug, Deserialize)]
64pub struct EnvWrapper {
65    #[serde(default)]
66    pub environments: Option<EnvCollection>,
67}
68
69#[derive(Debug, Deserialize)]
70#[serde(untagged)]
71pub enum EnvCollection {
72    Named(BTreeMap<String, Value>),
73    List(Vec<EnvRef>),
74}
75
76#[derive(Debug, Deserialize)]
77pub struct EnvRef {
78    #[serde(default)]
79    pub name: Option<String>,
80}
81
82impl EnvListResponse {
83    /// Whether an environment named `instance` is present.
84    pub fn contains(&self, instance: &str) -> bool {
85        let in_list = |list: &[EnvRef]| list.iter().any(|e| e.name.as_deref() == Some(instance));
86        match self {
87            Self::Wrapped(wrapper) => match &wrapper.environments {
88                Some(EnvCollection::Named(map)) => map.contains_key(instance),
89                Some(EnvCollection::List(list)) => in_list(list),
90                None => false,
91            },
92            Self::Bare(list) => in_list(list),
93        }
94    }
95}
96
97/// `stripe projects services list --json` data.
98#[derive(Debug, Default, Deserialize)]
99pub struct ServicesListResponse {
100    #[serde(default)]
101    pub services: Vec<ServiceRef>,
102}
103
104impl ServicesListResponse {
105    /// Whether a registered service named `name` exists.
106    pub fn contains(&self, name: &str) -> bool {
107        self.services
108            .iter()
109            .any(|s| s.name.as_deref() == Some(name))
110    }
111}
112
113#[derive(Debug, Deserialize)]
114pub struct ServiceRef {
115    #[serde(default)]
116    pub name: Option<String>,
117}
118
119/// Extract preflight rows from a full `{ok, data, error}` envelope.
120pub fn preflight_checks_from_envelope(raw: &str) -> Vec<PreflightCheck> {
121    #[derive(Deserialize)]
122    struct Envelope {
123        #[serde(default)]
124        ok: bool,
125        #[serde(default)]
126        data: Option<Value>,
127        #[serde(default)]
128        error: Option<PreflightError>,
129    }
130    #[derive(Deserialize)]
131    struct PreflightError {
132        #[serde(default)]
133        details: Option<Value>,
134    }
135    let envelope: Envelope = match serde_json::from_str(raw) {
136        Ok(e) => e,
137        Err(_) => return Vec::new(),
138    };
139    let data = envelope.data.unwrap_or(Value::Null);
140    let details = envelope.error.and_then(|e| e.details);
141    preflight_checks_from_parts(&data, envelope.ok, details.as_ref())
142}
143
144/// Extract preflight rows from already-parsed Stripe result parts.
145pub fn preflight_checks_from_parts(
146    data: &Value,
147    ok: bool,
148    error_details: Option<&Value>,
149) -> Vec<PreflightCheck> {
150    if ok {
151        return serde_json::from_value::<PreflightReady>(data.clone())
152            .map(|ready| ready.preflight)
153            .unwrap_or_default();
154    }
155    let Some(details) = error_details else {
156        return Vec::new();
157    };
158    #[derive(Deserialize)]
159    struct Details {
160        #[serde(default)]
161        preflight: Vec<PreflightCheck>,
162    }
163    serde_json::from_value::<Details>(details.clone())
164        .map(|d| d.preflight)
165        .unwrap_or_default()
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use serde_json::json;
172
173    const PREFLIGHT_INIT_BLOCKED: &str = include_str!(concat!(
174        env!("CARGO_MANIFEST_DIR"),
175        "/tests/fixtures/probes/preflight-init-blocked.json"
176    ));
177    const PREFLIGHT_INIT_READY: &str = include_str!(concat!(
178        env!("CARGO_MANIFEST_DIR"),
179        "/tests/fixtures/probes/preflight-init-ready.json"
180    ));
181    const ENV_LIST: &str = include_str!(concat!(
182        env!("CARGO_MANIFEST_DIR"),
183        "/tests/fixtures/probes/env-list.json"
184    ));
185
186    #[test]
187    fn status_reads_project_id() {
188        let r: StatusResponse =
189            serde_json::from_value(json!({"project": {"id": "proj_1"}})).unwrap();
190        assert_eq!(r.project_id(), Some("proj_1"));
191        let empty: StatusResponse = serde_json::from_value(json!({})).unwrap();
192        assert_eq!(empty.project_id(), None);
193    }
194
195    #[test]
196    fn env_list_handles_all_three_shapes() {
197        let named: EnvListResponse =
198            serde_json::from_value(json!({"environments": {"feat-x": {}}})).unwrap();
199        assert!(named.contains("feat-x"));
200        let listed: EnvListResponse =
201            serde_json::from_value(json!({"environments": [{"name": "feat-x"}]})).unwrap();
202        assert!(listed.contains("feat-x"));
203        let bare: EnvListResponse = serde_json::from_value(json!([{"name": "feat-x"}])).unwrap();
204        assert!(bare.contains("feat-x"));
205        assert!(!bare.contains("other"));
206    }
207
208    #[test]
209    fn env_list_parses_probe_fixture() {
210        #[derive(Deserialize)]
211        struct Envelope {
212            data: EnvListResponse,
213        }
214        let start = ENV_LIST.find('{').expect("fixture contains JSON object");
215        let envelope: Envelope = serde_json::from_str(&ENV_LIST[start..]).unwrap();
216        assert!(envelope.data.contains("default"));
217    }
218
219    #[test]
220    fn preflight_blocked_fixture_lists_failures() {
221        let checks = preflight_checks_from_envelope(PREFLIGHT_INIT_BLOCKED);
222        let failures: Vec<_> = checks.iter().filter(|c| !c.pass).collect();
223        assert_eq!(failures.len(), 2);
224        assert!(
225            failures
226                .iter()
227                .any(|c| c.code.as_deref() == Some("TOS_ACCEPTANCE_REQUIRED"))
228        );
229    }
230
231    #[test]
232    fn preflight_ready_fixture_passes() {
233        let checks = preflight_checks_from_envelope(PREFLIGHT_INIT_READY);
234        assert!(checks.iter().all(|c| c.pass));
235    }
236
237    #[test]
238    fn services_list_contains() {
239        let r: ServicesListResponse =
240            serde_json::from_value(json!({"services": [{"name": "atto-web"}]})).unwrap();
241        assert!(r.contains("atto-web"));
242        assert!(!r.contains("missing"));
243    }
244}