Skip to main content

stackless_stripe_projects/catalog/
verify.rs

1//! The catalog-anchored provisioning seam.
2//!
3//! Every provisionable service implements [`CatalogService`] (a reference plus a
4//! `Serialize` config). [`add_catalog_resource`] is the single path to
5//! `stripe projects add`: it validates the config against the catalog schema and
6//! derives paid confirmation from the selected pricing tier. [`verify_service`]
7//! is the test-time gap check that reuses the exact same validation.
8
9use serde::Serialize;
10use serde_json::{Value, json};
11
12use crate::catalog::Catalog;
13use crate::error::ProjectsError;
14use crate::project::{self, AddedResource};
15use crate::stripe::{CommandRunner, StripeProjects};
16
17/// A provisionable catalog service: a typed config bound to a catalog reference.
18pub trait CatalogService: Serialize {
19    /// The `stripe projects add <reference>` key, e.g. `"render/postgres"`.
20    const REFERENCE: &'static str;
21}
22
23/// Add a catalog resource: look up the reference, validate the serialized config
24/// against the catalog schema, derive paid confirmation from the selected tier,
25/// then `stripe projects add`. Returns the attached local name and add payload.
26pub async fn add_catalog_resource<C, R>(
27    stripe: &StripeProjects<R>,
28    catalog: &Catalog,
29    config: &C,
30    resource_name: &str,
31) -> Result<AddedResource, ProjectsError>
32where
33    C: CatalogService,
34    R: CommandRunner,
35{
36    add_catalog_resource_with_paid(stripe, catalog, config, resource_name, false).await
37}
38
39/// Like [`add_catalog_resource`], but passes explicit paid consent into
40/// component-pricing tier selection and parent-plan provisioning.
41pub async fn add_catalog_resource_with_paid<C, R>(
42    stripe: &StripeProjects<R>,
43    catalog: &Catalog,
44    config: &C,
45    resource_name: &str,
46    confirm_paid: bool,
47) -> Result<AddedResource, ProjectsError>
48where
49    C: CatalogService,
50    R: CommandRunner,
51{
52    let value = serde_json::to_value(config).map_err(|err| ProjectsError::ProvisionFailed {
53        resource: resource_name.to_owned(),
54        detail: format!("config for {} did not serialize: {err}", C::REFERENCE),
55    })?;
56    let service = catalog
57        .lookup(C::REFERENCE)
58        .ok_or_else(|| ProjectsError::CatalogMissing {
59            reference: C::REFERENCE.to_owned(),
60        })?;
61    service
62        .validate_config(&value)
63        .map_err(|violations| ProjectsError::ConfigSchema {
64            reference: C::REFERENCE,
65            violations,
66        })?;
67    let paid = service.requires_confirmation_with_paid(&value, confirm_paid);
68    ensure_parent_plans(stripe, catalog, service, &value, confirm_paid).await?;
69    project::add_resource(stripe, C::REFERENCE, resource_name, &value, paid).await
70}
71
72/// Provision catalog-named parent plans before a dependent service. Stripe
73/// Projects 0.23+ enforces `PLAN_REQUIRED` when `parent_services` is unset.
74async fn ensure_parent_plans<R: CommandRunner>(
75    stripe: &StripeProjects<R>,
76    catalog: &Catalog,
77    service: &crate::catalog::ServiceDetail,
78    config: &Value,
79    prefer_paid: bool,
80) -> Result<(), ProjectsError> {
81    for plan_id in service.required_parent_services(config, prefer_paid) {
82        let reference = format!("{}/{}", service.provider_name.to_ascii_lowercase(), plan_id);
83        let plan = catalog
84            .lookup(&reference)
85            .ok_or_else(|| ProjectsError::ProvisionFailed {
86                resource: plan_id.clone(),
87                detail: format!("parent plan {reference} not found in catalog"),
88            })?;
89        let needs_paid = plan.requires_confirmation_with_paid(&json!({}), prefer_paid);
90        let paid = needs_paid && prefer_paid;
91        project::add_resource(stripe, &reference, &plan_id, &json!({}), paid).await?;
92    }
93    Ok(())
94}
95
96/// Whether provisioning `config` for `reference` needs paid confirmation, per the
97/// catalog's selected pricing tier. Returns `None` if the reference is absent.
98pub fn requires_confirmation<C>(catalog: &Catalog, config: &C) -> Option<bool>
99where
100    C: CatalogService,
101{
102    let value = serde_json::to_value(config).ok()?;
103    catalog
104        .lookup(C::REFERENCE)
105        .map(|service| service.requires_confirmation(&value))
106}
107
108/// Test-time gap check: assert a service's reference exists in `catalog` and a
109/// representative config validates against its schema + pricing tiers. Returns
110/// violation strings (empty means no gap). Reuses the runtime validator.
111pub fn verify_service<C>(catalog: &Catalog, sample: &C) -> Vec<String>
112where
113    C: CatalogService,
114{
115    let mut out = Vec::new();
116    let Some(service) = catalog.lookup(C::REFERENCE) else {
117        out.push(format!("{}: reference not found in catalog", C::REFERENCE));
118        return out;
119    };
120    match serde_json::to_value(sample) {
121        Ok(value) => {
122            if let Err(violations) = service.validate_config(&value) {
123                out.extend(
124                    violations
125                        .into_iter()
126                        .map(|v| format!("{}: {v}", C::REFERENCE)),
127                );
128            }
129        }
130        Err(err) => out.push(format!(
131            "{}: sample config did not serialize: {err}",
132            C::REFERENCE
133        )),
134    }
135    out
136}