greentic_dev/
distributor.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use anyhow::{Context, Result, bail};
5use reqwest::blocking::Client;
6use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
7
8use crate::config::{DistributorProfileConfig, GreenticConfig};
9
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum DevIntent {
13    Dev,
14    Runtime,
15}
16
17#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19pub enum DevArtifactKind {
20    Component,
21    Pack,
22}
23
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
25pub struct DevResolveRequest {
26    pub coordinate: String,
27    pub intent: DevIntent,
28    pub platform: Option<String>,
29    pub features: Vec<String>,
30}
31
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum DevLicenseType {
35    Free,
36    Commercial,
37    Trial,
38}
39
40#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
41pub struct DevLicenseInfo {
42    pub license_type: DevLicenseType,
43    pub id: Option<String>,
44    pub requires_acceptance: bool,
45    pub checkout_url: Option<String>,
46}
47
48#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
49pub struct DevResolveResponse {
50    pub kind: DevArtifactKind,
51    pub name: String,
52    pub version: String,
53    pub coordinate: String,
54    pub artifact_id: String,
55    pub artifact_download_path: String,
56    pub digest: Option<String>,
57    pub license: DevLicenseInfo,
58    pub metadata: serde_json::Value,
59}
60
61#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
62pub struct DevLicenseRequiredErrorBody {
63    pub error: String,
64    pub coordinate: String,
65    pub message: String,
66    pub checkout_url: String,
67}
68
69#[derive(Debug)]
70pub enum DevDistributorError {
71    Http(reqwest::Error),
72    LicenseRequired(DevLicenseRequiredErrorBody),
73    Status(reqwest::StatusCode, Option<String>),
74    InvalidResponse(anyhow::Error),
75}
76
77impl std::fmt::Display for DevDistributorError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            DevDistributorError::Http(err) => write!(f, "http error: {err}"),
81            DevDistributorError::LicenseRequired(body) => {
82                write!(f, "{} (checkout: {})", body.message, body.checkout_url)
83            }
84            DevDistributorError::Status(code, body) => {
85                if let Some(body) = body {
86                    write!(f, "unexpected status {code}: {body}")
87                } else {
88                    write!(f, "unexpected status {code}")
89                }
90            }
91            DevDistributorError::InvalidResponse(err) => write!(f, "invalid response: {err}"),
92        }
93    }
94}
95
96impl std::error::Error for DevDistributorError {}
97
98impl From<reqwest::Error> for DevDistributorError {
99    fn from(value: reqwest::Error) -> Self {
100        DevDistributorError::Http(value)
101    }
102}
103
104#[derive(Debug, Clone)]
105pub struct DistributorProfile {
106    pub name: String,
107    pub url: String,
108    pub token: Option<String>,
109    pub tenant_id: String,
110    pub environment_id: String,
111    pub headers: Option<HashMap<String, String>>,
112}
113
114impl DistributorProfile {
115    fn from_pair(name: &str, cfg: &DistributorProfileConfig) -> Result<Self> {
116        let token = resolve_token(cfg.token.clone())?;
117        let base_url = cfg
118            .base_url
119            .as_ref()
120            .or(cfg.url.as_ref())
121            .map(|s| s.trim_end_matches('/').to_string())
122            .unwrap_or_else(|| "http://localhost:8080".to_string());
123        let tenant_id = cfg.tenant_id.clone().unwrap_or_else(|| "local".to_string());
124        let environment_id = cfg
125            .environment_id
126            .clone()
127            .unwrap_or_else(|| "dev".to_string());
128        Ok(Self {
129            name: name.to_string(),
130            url: base_url,
131            token,
132            tenant_id,
133            environment_id,
134            headers: cfg.headers.clone(),
135        })
136    }
137}
138
139pub fn resolve_profile(
140    config: &GreenticConfig,
141    profile_arg: Option<&str>,
142) -> Result<DistributorProfile> {
143    let env_profile = std::env::var("GREENTIC_DISTRIBUTOR_PROFILE").ok();
144    let profile_name = profile_arg.or(env_profile.as_deref()).unwrap_or("default");
145    let map: &HashMap<String, DistributorProfileConfig> = &config.distributor.profiles;
146    let Some(profile_cfg) = map.get(profile_name) else {
147        bail!(
148            "distributor profile `{profile_name}` not found; configure it in ~/.config/greentic-dev/config.toml"
149        );
150    };
151    DistributorProfile::from_pair(profile_name, profile_cfg)
152}
153
154fn resolve_token(raw: Option<String>) -> Result<Option<String>> {
155    let Some(raw) = raw else {
156        return Ok(None);
157    };
158    if let Some(rest) = raw.strip_prefix("env:") {
159        let value = std::env::var(rest)
160            .with_context(|| format!("failed to resolve env var {rest} for distributor token"))?;
161        Ok(Some(value))
162    } else {
163        Ok(Some(raw))
164    }
165}
166
167#[derive(Debug, Clone)]
168pub struct DevDistributorClient {
169    base_url: String,
170    auth_token: Option<String>,
171    http: Client,
172}
173
174impl DevDistributorClient {
175    pub fn from_profile(profile: DistributorProfile) -> Result<Self> {
176        let client = Client::builder()
177            .timeout(Duration::from_secs(30))
178            .build()
179            .context("failed to build HTTP client")?;
180        Ok(Self {
181            base_url: profile.url,
182            auth_token: profile.token,
183            http: client,
184        })
185    }
186
187    pub fn resolve(
188        &self,
189        req: &DevResolveRequest,
190    ) -> Result<DevResolveResponse, DevDistributorError> {
191        let url = format!("{}/v1/resolve", self.base_url);
192        let mut builder = self.http.post(url).header(CONTENT_TYPE, "application/json");
193        if let Some(token) = &self.auth_token {
194            builder = builder.header(AUTHORIZATION, format!("Bearer {token}"));
195        }
196        let response = builder.json(req).send()?;
197        if response.status().as_u16() == 402 {
198            let body: DevLicenseRequiredErrorBody = response
199                .json()
200                .map_err(|err| DevDistributorError::InvalidResponse(err.into()))?;
201            return Err(DevDistributorError::LicenseRequired(body));
202        }
203        if !response.status().is_success() {
204            let status = response.status();
205            let body = response.text().ok();
206            return Err(DevDistributorError::Status(status, body));
207        }
208        response
209            .json::<DevResolveResponse>()
210            .map_err(|err| DevDistributorError::InvalidResponse(err.into()))
211    }
212
213    pub fn download_artifact(
214        &self,
215        download_path: &str,
216    ) -> Result<bytes::Bytes, DevDistributorError> {
217        let trimmed_base = self.base_url.trim_end_matches('/');
218        let trimmed_path = download_path.trim_start_matches('/');
219        let url = format!("{trimmed_base}/{trimmed_path}");
220        let mut builder = self.http.get(url);
221        if let Some(token) = &self.auth_token {
222            builder = builder.header(AUTHORIZATION, format!("Bearer {token}"));
223        }
224        let response = builder.send()?;
225        if !response.status().is_success() {
226            let status = response.status();
227            let body = response.text().ok();
228            return Err(DevDistributorError::Status(status, body));
229        }
230        response.bytes().map_err(DevDistributorError::Http)
231    }
232}