use std::collections::HashMap;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use reqwest::blocking::Client;
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
use crate::config::{
DefaultProfileSelection, DistributorProfileConfig, GreenticConfig, LoadedGreenticConfig,
};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DevIntent {
Dev,
Runtime,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DevArtifactKind {
Component,
Pack,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DevResolveRequest {
pub coordinate: String,
pub intent: DevIntent,
pub platform: Option<String>,
pub features: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DevLicenseType {
Free,
Commercial,
Trial,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DevLicenseInfo {
pub license_type: DevLicenseType,
pub id: Option<String>,
pub requires_acceptance: bool,
pub checkout_url: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DevResolveResponse {
pub kind: DevArtifactKind,
pub name: String,
pub version: String,
pub coordinate: String,
pub artifact_id: String,
pub artifact_download_path: String,
pub digest: Option<String>,
pub license: DevLicenseInfo,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DevLicenseRequiredErrorBody {
pub error: String,
pub coordinate: String,
pub message: String,
pub checkout_url: String,
}
#[derive(Debug)]
pub enum DevDistributorError {
Http(reqwest::Error),
LicenseRequired(DevLicenseRequiredErrorBody),
Status(reqwest::StatusCode, Option<String>),
InvalidResponse(anyhow::Error),
}
impl std::fmt::Display for DevDistributorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DevDistributorError::Http(err) => write!(f, "http error: {err}"),
DevDistributorError::LicenseRequired(body) => {
write!(f, "{} (checkout: {})", body.message, body.checkout_url)
}
DevDistributorError::Status(code, body) => {
if let Some(body) = body {
write!(f, "unexpected status {code}: {body}")
} else {
write!(f, "unexpected status {code}")
}
}
DevDistributorError::InvalidResponse(err) => write!(f, "invalid response: {err}"),
}
}
}
impl std::error::Error for DevDistributorError {}
impl From<reqwest::Error> for DevDistributorError {
fn from(value: reqwest::Error) -> Self {
DevDistributorError::Http(value)
}
}
#[derive(Debug, Clone)]
pub struct DistributorProfile {
pub name: String,
pub url: String,
pub token: Option<String>,
pub tenant_id: String,
pub environment_id: String,
pub headers: Option<HashMap<String, String>>,
}
impl DistributorProfile {
fn from_pair(name: &str, cfg: &DistributorProfileConfig) -> Result<Self> {
let token = resolve_token(cfg.token.clone())?;
let base_url = cfg
.base_url
.as_ref()
.or(cfg.url.as_ref())
.map(|s| s.trim_end_matches('/').to_string())
.unwrap_or_else(|| "http://localhost:8080".to_string());
let tenant_id = cfg.tenant_id.clone().unwrap_or_else(|| "local".to_string());
let environment_id = cfg
.environment_id
.clone()
.unwrap_or_else(|| "dev".to_string());
Ok(Self {
name: cfg.name.clone().unwrap_or_else(|| name.to_string()),
url: base_url,
token,
tenant_id,
environment_id,
headers: cfg.headers.clone(),
})
}
}
pub fn resolve_profile(
config: &LoadedGreenticConfig,
profile_arg: Option<&str>,
) -> Result<DistributorProfile> {
let env_profile = std::env::var("GREENTIC_DISTRIBUTOR_PROFILE").ok();
let map: HashMap<String, DistributorProfileConfig> = config.config.distributor_profiles();
let selection = select_profile(profile_arg, env_profile.as_deref(), &config.config);
match selection {
ProfileSelection::Inline(cfg) => {
let name = cfg.name.clone().unwrap_or_else(|| "default".to_string());
DistributorProfile::from_pair(&name, &cfg)
}
ProfileSelection::Named(profile_name) => {
let Some(profile_cfg) = map.get(&profile_name) else {
let mut available_profiles = map.keys().cloned().collect::<Vec<_>>();
available_profiles.sort();
let available = if available_profiles.is_empty() {
"<none>".to_string()
} else {
available_profiles.join(", ")
};
let loaded = config
.loaded_from
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(no config file loaded)".to_string());
let attempted = if config.attempted_paths.is_empty() {
"(none)".to_string()
} else {
config
.attempted_paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
};
bail!(
"distributor profile `{profile_name}` not found in {} (available: {}). searched config paths: {}. Override with --profile, GREENTIC_DISTRIBUTOR_PROFILE, or GREENTIC_DEV_CONFIG_FILE.",
loaded,
available,
attempted
);
};
DistributorProfile::from_pair(&profile_name, profile_cfg)
}
}
}
fn resolve_token(raw: Option<String>) -> Result<Option<String>> {
let Some(raw) = raw else {
return Ok(None);
};
if let Some(rest) = raw.strip_prefix("env:") {
let value = std::env::var(rest)
.with_context(|| format!("failed to resolve env var {rest} for distributor token"))?;
Ok(Some(value))
} else {
Ok(Some(raw))
}
}
fn select_profile(
arg: Option<&str>,
env: Option<&str>,
config: &GreenticConfig,
) -> ProfileSelection {
if let Some(arg) = arg {
return ProfileSelection::Named(arg.to_string());
}
if let Some(env) = env {
return ProfileSelection::Named(env.to_string());
}
if let Some(default) = &config.distributor.default_profile {
return match default {
DefaultProfileSelection::Name(name) => ProfileSelection::Named(name.clone()),
DefaultProfileSelection::Inline(cfg) => ProfileSelection::Inline(cfg.clone()),
};
}
ProfileSelection::Named("default".to_string())
}
enum ProfileSelection {
Named(String),
Inline(DistributorProfileConfig),
}
#[derive(Debug, Clone)]
pub struct DevDistributorClient {
base_url: String,
auth_token: Option<String>,
http: Client,
}
impl DevDistributorClient {
pub fn from_profile(profile: DistributorProfile) -> Result<Self> {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.context("failed to build HTTP client")?;
Ok(Self {
base_url: profile.url,
auth_token: profile.token,
http: client,
})
}
pub fn resolve(
&self,
req: &DevResolveRequest,
) -> Result<DevResolveResponse, DevDistributorError> {
let url = format!("{}/v1/resolve", self.base_url);
let mut builder = self.http.post(url).header(CONTENT_TYPE, "application/json");
if let Some(token) = &self.auth_token {
builder = builder.header(AUTHORIZATION, format!("Bearer {token}"));
}
let response = builder.json(req).send()?;
if response.status().as_u16() == 402 {
let body: DevLicenseRequiredErrorBody = response
.json()
.map_err(|err| DevDistributorError::InvalidResponse(err.into()))?;
return Err(DevDistributorError::LicenseRequired(body));
}
if !response.status().is_success() {
let status = response.status();
let body = response.text().ok();
return Err(DevDistributorError::Status(status, body));
}
response
.json::<DevResolveResponse>()
.map_err(|err| DevDistributorError::InvalidResponse(err.into()))
}
pub fn download_artifact(
&self,
download_path: &str,
) -> Result<bytes::Bytes, DevDistributorError> {
let trimmed_base = self.base_url.trim_end_matches('/');
let trimmed_path = download_path.trim_start_matches('/');
let url = format!("{trimmed_base}/{trimmed_path}");
let mut builder = self.http.get(url);
if let Some(token) = &self.auth_token {
builder = builder.header(AUTHORIZATION, format!("Bearer {token}"));
}
let response = builder.send()?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().ok();
return Err(DevDistributorError::Status(status, body));
}
response.bytes().map_err(DevDistributorError::Http)
}
}