Skip to main content

sail/
app.rs

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