Skip to main content

sail/
app.rs

1//! Typed app discovery.
2//!
3//! An app is the billing and ownership scope a Sailbox belongs to. Look one up
4//! by name with [`Client::find_app`](crate::Client::find_app) or list the
5//! org's apps with [`Client::list_apps`](crate::Client::list_apps); both map
6//! failures onto the canonical [`SailError`] taxonomy.
7
8use serde::Serialize;
9use serde_json::{json, Value};
10use time::OffsetDateTime;
11
12use crate::error::SailError;
13use crate::http::{HttpCore, IdempotencyKey, Method, RequestSpec};
14use crate::retry::DEFAULT_RETRY_POLICY;
15
16/// A Sail application.
17#[derive(Debug, Clone, Serialize)]
18#[non_exhaustive]
19pub struct App {
20    /// Stable server-assigned app identifier.
21    pub id: String,
22    /// Human-readable app name unique within the owning org.
23    pub name: String,
24    /// App creation time.
25    #[serde(with = "time::serde::rfc3339")]
26    pub created_at: OffsetDateTime,
27}
28
29impl App {
30    /// Parse an app row from an API response object, defaulting missing fields.
31    fn from_json(row: &Value) -> App {
32        App {
33            id: row
34                .get("id")
35                .and_then(Value::as_str)
36                .unwrap_or("")
37                .to_string(),
38            name: row
39                .get("name")
40                .and_then(Value::as_str)
41                .unwrap_or("")
42                .to_string(),
43            // Arrives as a Unix integer; re-serialized as RFC 3339 like every
44            // other bindings timestamp.
45            created_at: OffsetDateTime::from_unix_timestamp(
46                row.get("created_at").and_then(Value::as_i64).unwrap_or(0),
47            )
48            .unwrap_or(OffsetDateTime::UNIX_EPOCH),
49        }
50    }
51}
52
53/// Find an app by name on the central API, optionally minting it.
54pub(crate) async fn find_app(
55    api_http: &HttpCore,
56    name: &str,
57    mint_if_missing: bool,
58) -> Result<App, SailError> {
59    let body = json!({ "name": name, "mint_if_missing": mint_if_missing });
60    let bytes = serde_json::to_vec(&body).map_err(|e| SailError::Internal {
61        message: format!("failed to serialize request body: {e}"),
62    })?;
63    let (status, data) = api_http
64        .request(&RequestSpec {
65            method: Method::Post,
66            path: "/v1/apps/find".to_string(),
67            query: Vec::new(),
68            body: Some(bytes),
69            extra_headers: Vec::new(),
70            timeout: None,
71            policy: DEFAULT_RETRY_POLICY,
72            idempotency_key: IdempotencyKey::Auto,
73        })
74        .await?;
75    // find returns 404 for any miss (not type-gated, unlike sailbox lookups).
76    if status == 404 {
77        return Err(SailError::NotFound {
78            message: format!("App {name:?} not found"),
79        });
80    }
81    raise_app_error(status, &data, "Failed to find app")?;
82    Ok(App::from_json(&data))
83}
84
85/// Every app the org owns, newest first, from the central app index.
86///
87/// Reads the canonical apps table, so apps with no Sailboxes yet are included.
88/// The response is not paginated; the per-org app count is small.
89pub(crate) async fn list_apps(api_http: &HttpCore) -> Result<Vec<App>, SailError> {
90    let (status, data) = api_http
91        .request(&RequestSpec {
92            method: Method::Get,
93            path: "/v1/apps".to_string(),
94            query: Vec::new(),
95            body: None,
96            extra_headers: Vec::new(),
97            timeout: None,
98            policy: DEFAULT_RETRY_POLICY,
99            idempotency_key: IdempotencyKey::Auto,
100        })
101        .await?;
102    raise_app_error(status, &data, "Failed to list apps")?;
103    let Some(rows) = data.get("data").and_then(Value::as_array) else {
104        return Ok(Vec::new());
105    };
106    Ok(rows.iter().map(App::from_json).collect())
107}
108
109/// App ladder: 401/403 → PermissionDenied, any other non-2xx → Api
110/// (transient/unexpected). 404 is handled by the caller where it is meaningful.
111fn raise_app_error(status: u16, data: &Value, context: &str) -> Result<(), SailError> {
112    if status < 300 {
113        return Ok(());
114    }
115    let detail = crate::http::api_error_message(data, "unknown error");
116    let message = format!("{context}: {detail}");
117    if status == 401 || status == 403 {
118        return Err(SailError::PermissionDenied { message });
119    }
120    Err(SailError::Api {
121        status,
122        message,
123        body: data.clone(),
124    })
125}