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