Skip to main content

deslicer_cli/
resolver.rs

1use crate::ci::{CiPlatform, AUDIENCE};
2use crate::errors::CliError;
3use crate::Ctx;
4use serde::Serialize;
5
6#[derive(Debug, Clone)]
7pub struct ResolvedBackend {
8    pub observer_api_url: url::Url,
9    pub audience: String,
10    pub resolution_path: String,
11}
12
13#[derive(Serialize)]
14struct ResolveBackendRequest<'a> {
15    #[serde(skip_serializing_if = "Option::is_none")]
16    repo: Option<String>,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    environment: Option<&'a str>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    plan_id: Option<&'a str>,
21}
22
23#[derive(serde::Deserialize)]
24struct ResolveBackendResponse {
25    observer_api_url: String,
26    audience: String,
27    resolution_path: String,
28}
29
30pub async fn resolve(
31    ctx: &Ctx,
32    jwt: &str,
33    platform: CiPlatform,
34    environment: Option<&str>,
35    plan_id: Option<&str>,
36) -> Result<ResolvedBackend, CliError> {
37    if let Some(url) = ctx.observer_api_url.clone() {
38        return Ok(ResolvedBackend {
39            observer_api_url: url,
40            audience: AUDIENCE.to_string(),
41            resolution_path: "observer_url_override".to_string(),
42        });
43    }
44
45    let url = join_api_path(&ctx.deslicer_api_url, "api/cli/resolve-backend")?;
46    let body = ResolveBackendRequest {
47        repo: repo_from_ci(platform),
48        environment,
49        plan_id,
50    };
51
52    let http = reqwest::Client::new();
53    let response = http
54        .post(url)
55        .header("Authorization", format!("Bearer {jwt}"))
56        .header("X-Deslicer-CI-Platform", platform.header_value())
57        .header("Content-Type", "application/json")
58        .json(&body)
59        .send()
60        .await
61        .map_err(|e| CliError::Transport(e.to_string()))?;
62
63    let status = response.status();
64    let retry_after = parse_retry_after_header(response.headers());
65    let response_body = response
66        .text()
67        .await
68        .map_err(|e| CliError::Transport(e.to_string()))?;
69
70    if status.is_success() {
71        let parsed: ResolveBackendResponse = serde_json::from_str(&response_body)
72            .map_err(|e| CliError::Transport(format!("invalid resolve-backend JSON: {e}")))?;
73        let observer_api_url = url::Url::parse(&parsed.observer_api_url).map_err(|e| {
74            CliError::Transport(format!("invalid observer_api_url in response: {e}"))
75        })?;
76        return Ok(ResolvedBackend {
77            observer_api_url,
78            audience: parsed.audience,
79            resolution_path: parsed.resolution_path,
80        });
81    }
82
83    Err(map_resolver_error(status, &response_body, retry_after))
84}
85
86fn repo_from_ci(platform: CiPlatform) -> Option<String> {
87    let key = match platform {
88        CiPlatform::Github => "GITHUB_REPOSITORY",
89        CiPlatform::Gitlab => "CI_PROJECT_PATH",
90        CiPlatform::Azure => "BUILD_REPOSITORY_NAME",
91        CiPlatform::Bitbucket => "BITBUCKET_REPO_FULL_NAME",
92        CiPlatform::Local => return None,
93    };
94    std::env::var(key)
95        .ok()
96        .map(|s| s.trim().to_string())
97        .filter(|s| !s.is_empty())
98}
99
100fn join_api_path(base: &url::Url, path: &str) -> Result<url::Url, CliError> {
101    base.join(path)
102        .map_err(|e| CliError::Transport(format!("invalid URL join: {e}")))
103}
104
105fn map_resolver_error(
106    status: reqwest::StatusCode,
107    body: &str,
108    retry_after_secs: Option<u64>,
109) -> CliError {
110    let message = error_message(body, status);
111    match status.as_u16() {
112        401 => CliError::OidcRejected(message),
113        403 => {
114            if mentions_environment(body) {
115                CliError::EnvironmentNotBound(message)
116            } else {
117                CliError::RepoNotAllowlisted(message)
118            }
119        }
120        404 => CliError::PlanNotFound(message),
121        409 => CliError::AmbiguousBinding(message),
122        429 => CliError::RateLimited {
123            retry_after_secs: retry_after_secs.unwrap_or(30),
124        },
125        500..=599 => CliError::BackendUnavailable(status.to_string()),
126        _ => CliError::Other(message),
127    }
128}
129
130fn error_message(body: &str, status: reqwest::StatusCode) -> String {
131    if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
132        for key in ["detail", "error", "message"] {
133            if let Some(text) = value.get(key).and_then(|v| v.as_str()) {
134                if !text.is_empty() {
135                    return text.to_string();
136                }
137            }
138        }
139    }
140    if body.trim().is_empty() {
141        format!("HTTP {status}")
142    } else {
143        body.trim().to_string()
144    }
145}
146
147fn mentions_environment(text: &str) -> bool {
148    text.to_ascii_lowercase().contains("environment")
149}
150
151fn parse_retry_after_header(headers: &reqwest::header::HeaderMap) -> Option<u64> {
152    headers
153        .get(reqwest::header::RETRY_AFTER)
154        .and_then(|v| v.to_str().ok())
155        .and_then(|s| s.trim().parse::<u64>().ok())
156}