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///
99/// The CLI returns both deployable **services** and companion **plans** in one
100/// payload. Parent-plan ensure must consult `plans` — matching only `services`
101/// misses orphan `clerk/hobby` rows and retries `projects add` into a 500.
102#[derive(Debug, Default, Deserialize)]
103pub struct ServicesListResponse {
104    #[serde(default)]
105    pub services: Vec<ServiceRef>,
106    #[serde(default)]
107    pub plans: Vec<ServiceRef>,
108}
109
110impl ServicesListResponse {
111    /// Whether a resource with this exact local name exists (service or plan).
112    pub fn contains(&self, name: &str) -> bool {
113        self.iter().any(|r| r.name.as_deref() == Some(name))
114    }
115
116    /// Iterate services and plans as one sequence (DTO stays policy-free).
117    pub fn iter(&self) -> impl Iterator<Item = &ServiceRef> {
118        self.services.iter().chain(self.plans.iter())
119    }
120}
121
122#[derive(Debug, Deserialize)]
123pub struct ServiceRef {
124    #[serde(default)]
125    pub name: Option<String>,
126    /// Catalog service id (`hobby`, `auth`, …), distinct from the local `--name`.
127    #[serde(default)]
128    pub service_id: Option<String>,
129    /// Provider that owns the row (`Clerk`, `vercel`, …). Required to scope reuse.
130    #[serde(default, alias = "provider")]
131    pub provider_name: Option<String>,
132}
133
134/// Extract preflight rows from a full `{ok, data, error}` envelope.
135pub fn preflight_checks_from_envelope(raw: &str) -> Vec<PreflightCheck> {
136    #[derive(Deserialize)]
137    struct Envelope {
138        #[serde(default)]
139        ok: bool,
140        #[serde(default)]
141        data: Option<Value>,
142        #[serde(default)]
143        error: Option<PreflightError>,
144    }
145    #[derive(Deserialize)]
146    struct PreflightError {
147        #[serde(default)]
148        details: Option<Value>,
149    }
150    let envelope: Envelope = match serde_json::from_str(raw) {
151        Ok(e) => e,
152        Err(_) => return Vec::new(),
153    };
154    let data = envelope.data.unwrap_or(Value::Null);
155    let details = envelope.error.and_then(|e| e.details);
156    preflight_checks_from_parts(&data, envelope.ok, details.as_ref())
157}
158
159/// Extract preflight rows from already-parsed Stripe result parts.
160pub fn preflight_checks_from_parts(
161    data: &Value,
162    ok: bool,
163    error_details: Option<&Value>,
164) -> Vec<PreflightCheck> {
165    if ok {
166        return serde_json::from_value::<PreflightReady>(data.clone())
167            .map(|ready| ready.preflight)
168            .unwrap_or_default();
169    }
170    let Some(details) = error_details else {
171        return Vec::new();
172    };
173    #[derive(Deserialize)]
174    struct Details {
175        #[serde(default)]
176        preflight: Vec<PreflightCheck>,
177    }
178    serde_json::from_value::<Details>(details.clone())
179        .map(|d| d.preflight)
180        .unwrap_or_default()
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use serde_json::json;
187
188    const PREFLIGHT_INIT_BLOCKED: &str = include_str!(concat!(
189        env!("CARGO_MANIFEST_DIR"),
190        "/tests/fixtures/probes/preflight-init-blocked.json"
191    ));
192    const PREFLIGHT_INIT_READY: &str = include_str!(concat!(
193        env!("CARGO_MANIFEST_DIR"),
194        "/tests/fixtures/probes/preflight-init-ready.json"
195    ));
196    const ENV_LIST: &str = include_str!(concat!(
197        env!("CARGO_MANIFEST_DIR"),
198        "/tests/fixtures/probes/env-list.json"
199    ));
200
201    #[test]
202    fn status_reads_project_id() {
203        let r: StatusResponse =
204            serde_json::from_value(json!({"project": {"id": "proj_1"}})).unwrap();
205        assert_eq!(r.project_id(), Some("proj_1"));
206        let empty: StatusResponse = serde_json::from_value(json!({})).unwrap();
207        assert_eq!(empty.project_id(), None);
208    }
209
210    #[test]
211    fn env_list_handles_all_three_shapes() {
212        let named: EnvListResponse =
213            serde_json::from_value(json!({"environments": {"feat-x": {}}})).unwrap();
214        assert!(named.contains("feat-x"));
215        let listed: EnvListResponse =
216            serde_json::from_value(json!({"environments": [{"name": "feat-x"}]})).unwrap();
217        assert!(listed.contains("feat-x"));
218        let bare: EnvListResponse = serde_json::from_value(json!([{"name": "feat-x"}])).unwrap();
219        assert!(bare.contains("feat-x"));
220        assert!(!bare.contains("other"));
221    }
222
223    #[test]
224    fn env_list_parses_probe_fixture() {
225        #[derive(Deserialize)]
226        struct Envelope {
227            data: EnvListResponse,
228        }
229        let start = ENV_LIST.find('{').expect("fixture contains JSON object");
230        let envelope: Envelope = serde_json::from_str(&ENV_LIST[start..]).unwrap();
231        assert!(envelope.data.contains("default"));
232    }
233
234    #[test]
235    fn preflight_blocked_fixture_lists_failures() {
236        let checks = preflight_checks_from_envelope(PREFLIGHT_INIT_BLOCKED);
237        let failures: Vec<_> = checks.iter().filter(|c| !c.pass).collect();
238        assert_eq!(failures.len(), 2);
239        assert!(
240            failures
241                .iter()
242                .any(|c| c.code.as_deref() == Some("TOS_ACCEPTANCE_REQUIRED"))
243        );
244    }
245
246    #[test]
247    fn preflight_ready_fixture_passes() {
248        let checks = preflight_checks_from_envelope(PREFLIGHT_INIT_READY);
249        assert!(checks.iter().all(|c| c.pass));
250    }
251
252    #[test]
253    fn services_list_contains() {
254        let r: ServicesListResponse =
255            serde_json::from_value(json!({"services": [{"name": "atto-web"}]})).unwrap();
256        assert!(r.contains("atto-web"));
257        assert!(!r.contains("missing"));
258    }
259
260    #[test]
261    fn services_list_contains_plans_by_name() {
262        let r: ServicesListResponse = serde_json::from_value(json!({
263            "services": [{"name": "clerk-auth", "service_id": "auth", "provider_name": "Clerk"}],
264            "plans": [{"name": "hobby", "service_id": "hobby", "provider_name": "Clerk"}]
265        }))
266        .unwrap();
267        assert!(r.contains("hobby"));
268        assert!(r.contains("clerk-auth"));
269    }
270
271    #[test]
272    fn services_list_accepts_provider_alias() {
273        let r: ServicesListResponse = serde_json::from_value(json!({
274            "plans": [{"name": "hobby", "service_id": "hobby", "provider": "vercel"}]
275        }))
276        .unwrap();
277        assert_eq!(r.plans[0].provider_name.as_deref(), Some("vercel"));
278    }
279}