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