1use crate::config::{AuthConfig, IfExistsAction, PublishConfig, RegistryConfig};
2use anyhow::Result;
3use reqwest::{
4 header::{HeaderMap, HeaderValue, AUTHORIZATION},
5 Client,
6};
7use semver::Version;
8use serde::Deserialize;
9use serde_json::{json, Value};
10use std::env;
11
12fn suggest_version_bump(version: &str) -> String {
14 if let Ok(parsed_version) = Version::parse(version) {
15 let mut new_version = parsed_version.clone();
17 new_version.patch += 1;
18 new_version.to_string()
19 } else {
20 if let Some(last_dot) = version.rfind('.') {
22 let (prefix, suffix) = version.split_at(last_dot + 1);
23 if let Ok(patch) = suffix.parse::<u64>() {
24 format!("{}{}", prefix, patch + 1)
25 } else {
26 format!("{version}.1")
27 }
28 } else {
29 format!("{version}.1")
30 }
31 }
32}
33
34#[allow(dead_code)]
35#[derive(Deserialize, Debug)]
36#[serde(rename_all = "camelCase")]
37pub struct ArtifactMetadata {
38 pub artifact_id: String,
39 pub artifact_type: String,
40 #[serde(default, alias = "groupId", alias = "group")]
41 pub group_id: Option<String>,
42}
43
44#[allow(dead_code)]
45#[derive(Deserialize, Debug)]
46#[serde(rename_all = "camelCase")]
47pub struct SystemInfo {
48 pub name: String,
49 pub description: String,
50 pub version: String,
51 pub built_on: String,
52}
53
54#[allow(dead_code)]
55#[derive(Deserialize, Debug, Clone)]
56#[serde(rename_all = "camelCase")]
57pub struct ArtifactVersionMetadata {
58 pub version: String,
59 pub artifact_type: String,
60 pub global_id: Option<i64>,
61 pub content_id: Option<i64>,
62 pub name: Option<String>,
63 pub description: Option<String>,
64 pub owner: Option<String>,
65 pub created_on: Option<String>,
66 pub labels: Option<std::collections::HashMap<String, String>>,
67}
68
69#[derive(Debug, Clone)]
70pub enum ReferenceType {
71 Outbound,
72 Inbound,
73}
74
75impl ReferenceType {
76 pub fn as_str(&self) -> &'static str {
77 match self {
78 ReferenceType::Outbound => "OUTBOUND",
79 ReferenceType::Inbound => "INBOUND",
80 }
81 }
82}
83
84#[allow(dead_code)]
85#[derive(Deserialize, Debug, Clone)]
86#[serde(rename_all = "camelCase")]
87pub struct ArtifactVersionReference {
88 pub group_id: Option<String>,
89 pub artifact_id: String,
90 pub version: String,
91 pub name: Option<String>,
92}
93
94pub struct RegistryClient {
95 #[allow(dead_code)]
96 pub name: String,
97 pub base_url: String,
98 pub client: Client,
99}
100
101impl RegistryClient {
102 pub fn new(cfg: &RegistryConfig) -> Result<Self> {
103 let mut headers = HeaderMap::new();
104 match &cfg.auth {
105 AuthConfig::None => {}
106 AuthConfig::Basic {
107 username,
108 password_env,
109 } => {
110 let pw = env::var(password_env)?;
111 let token = base64::encode_config(format!("{username}:{pw}"), base64::STANDARD);
112 let hv = HeaderValue::from_str(&format!("Basic {token}"))?;
113 headers.insert(AUTHORIZATION, hv);
114 }
115 AuthConfig::Token { token_env } => {
116 let tok = env::var(token_env)?;
117 let hv = HeaderValue::from_str(&tok)?;
118 headers.insert(AUTHORIZATION, hv);
119 }
120 AuthConfig::Bearer { token_env } => {
121 let tok = env::var(token_env)?;
122 let hv = HeaderValue::from_str(&format!("Bearer {tok}"))?;
123 headers.insert(AUTHORIZATION, hv);
124 }
125 }
126
127 let client = Client::builder().default_headers(headers).build()?;
128 Ok(RegistryClient {
129 name: cfg.name.clone(),
130 base_url: cfg.url.clone(),
131 client,
132 })
133 }
134
135 pub async fn list_versions(&self, group_id: &str, artifact_id: &str) -> Result<Vec<Version>> {
137 let url = format!(
138 "{}/apis/registry/v3/groups/{}/artifacts/{}/versions",
139 self.base_url, group_id, artifact_id
140 );
141 let resp = self.client.get(&url).send().await?.error_for_status()?;
142 #[derive(Deserialize)]
143 struct ApiResponse {
144 #[allow(dead_code)]
145 count: usize,
146 versions: Vec<ApiVersion>,
147 }
148
149 #[derive(Deserialize)]
150 struct ApiVersion {
151 version: String,
152 }
153
154 let api_response: ApiResponse = resp.json().await?;
155 let mut semver_versions = Vec::new();
156 for v in api_response.versions {
157 if let Ok(parsed) = Version::parse(&v.version) {
158 semver_versions.push(parsed);
159 }
160 }
161 Ok(semver_versions)
162 }
163
164 pub fn get_download_url(&self, group_id: &str, artifact_id: &str, version: &Version) -> String {
165 format!(
166 "{}/apis/registry/v3/groups/{}/artifacts/{}/versions/{}/content",
167 self.base_url, group_id, artifact_id, version
168 )
169 }
170
171 pub async fn download(
173 &self,
174 group_id: &str,
175 artifact_id: &str,
176 version: &Version,
177 ) -> Result<bytes::Bytes> {
178 let url = self.get_download_url(group_id, artifact_id, version);
179 let resp = self.client.get(&url).send().await?.error_for_status()?;
180 Ok(resp.bytes().await?)
181 }
182
183 pub async fn list_groups(&self) -> Result<Vec<String>> {
185 let url = format!("{}/apis/registry/v3/groups", self.base_url);
186 let resp = self.client.get(&url).send().await?.error_for_status()?;
187
188 #[derive(Deserialize)]
189 struct ApiResponse {
190 #[allow(dead_code)]
191 count: usize,
192 groups: Vec<ApiGroup>,
193 }
194
195 #[derive(Deserialize)]
196 struct ApiGroup {
197 #[serde(rename = "groupId")]
198 group_id: String,
199 }
200
201 let api_response: ApiResponse = resp.json().await?;
202 Ok(api_response
203 .groups
204 .into_iter()
205 .map(|g| g.group_id)
206 .collect())
207 }
208
209 pub async fn list_artifacts(&self, group_id: &str) -> Result<Vec<String>> {
211 let url = format!(
212 "{}/apis/registry/v3/groups/{}/artifacts",
213 self.base_url, group_id
214 );
215 let resp = self.client.get(&url).send().await?.error_for_status()?;
216
217 #[derive(Deserialize)]
218 struct ApiResponse {
219 #[allow(dead_code)]
220 count: usize,
221 artifacts: Vec<ApiArtifact>,
222 }
223
224 #[derive(Deserialize)]
225 struct ApiArtifact {
226 #[serde(rename = "artifactId")]
227 artifact_id: String,
228 }
229
230 let api_response: ApiResponse = resp.json().await?;
231 Ok(api_response
232 .artifacts
233 .into_iter()
234 .map(|a| a.artifact_id)
235 .collect())
236 }
237
238 pub async fn artifact_exists(&self, group_id: &str, artifact_id: &str) -> Result<bool> {
240 let url = format!(
241 "{}/apis/registry/v3/groups/{}/artifacts/{}",
242 self.base_url, group_id, artifact_id
243 );
244
245 match self.client.get(&url).send().await {
246 Ok(resp) => Ok(resp.status().is_success()),
247 Err(_) => Ok(false),
248 }
249 }
250
251 pub async fn get_artifact_metadata(
253 &self,
254 group_id: &str,
255 artifact_id: &str,
256 ) -> Result<ArtifactMetadata> {
257 let url = format!(
258 "{}/apis/registry/v3/groups/{}/artifacts/{}",
259 self.base_url, group_id, artifact_id
260 );
261 let resp = self.client.get(&url).send().await?.error_for_status()?;
262
263 let mut metadata: ArtifactMetadata = resp.json().await?;
264 if metadata.group_id.is_none() {
266 metadata.group_id = Some(group_id.to_string());
267 }
268 Ok(metadata)
269 }
270
271 pub async fn publish_artifact(&self, publish: &PublishConfig, content: &str) -> Result<()> {
273 let group_id = publish.resolved_group_id();
274 let artifact_id = publish.resolved_artifact_id();
275 let content_type = publish.resolved_content_type();
276 let artifact_type = publish.resolved_artifact_type();
277
278 if self
280 .version_exists(&group_id, &artifact_id, &publish.version)
281 .await?
282 {
283 match self
285 .get_version_content(&group_id, &artifact_id, &publish.version)
286 .await
287 {
288 Ok(existing_content) => {
289 if existing_content.trim() == content.trim() {
290 println!(
291 " ℹ️ Version {}@{} already published with identical content",
292 artifact_id, publish.version
293 );
294 return Ok(());
295 } else {
296 println!(
298 " ⚠️ Version {}@{} already exists with different content",
299 artifact_id, publish.version
300 );
301 println!(
302 " Consider bumping the version (e.g., {}) to publish the updated content",
303 suggest_version_bump(&publish.version)
304 );
305 anyhow::bail!("Cannot publish different content with same version");
306 }
307 }
308 Err(_) => {
309 println!(
311 " ⚠️ Version {}@{} exists but content comparison failed, proceeding with publish",
312 artifact_id, publish.version
313 );
314 }
315 }
316 }
317
318 let references: Vec<Value> = publish
320 .references
321 .iter()
322 .map(|r| {
323 json!({
324 "groupId": r.resolved_group_id(),
325 "artifactId": r.resolved_artifact_id(),
326 "version": r.version,
327 "name": r.name_alias.as_deref().unwrap_or(&r.resolved_artifact_id())
328 })
329 })
330 .collect();
331
332 let artifact_exists = self.artifact_exists(&group_id, &artifact_id).await?;
334
335 if artifact_exists {
336 let version_payload = json!({
338 "version": publish.version,
339 "content": {
340 "content": content,
341 "contentType": content_type,
342 "references": references
343 },
344 "name": &publish.name,
345 "description": publish.description.as_deref().unwrap_or(""),
346 "labels": {}
347 });
348
349 let url = format!(
350 "{}/apis/registry/v3/groups/{}/artifacts/{}/versions",
351 self.base_url.trim_end_matches('/'),
352 group_id,
353 artifact_id
354 );
355
356 let response = self
357 .client
358 .post(&url)
359 .header("Content-Type", "application/json")
360 .json(&version_payload)
361 .send()
362 .await?;
363
364 if response.status().is_success() {
365 println!(" ✅ Published {}@{}", artifact_id, publish.version);
366 Ok(())
367 } else {
368 let status = response.status();
369 let body = response
370 .text()
371 .await
372 .unwrap_or_else(|_| "Unknown error".to_string());
373 anyhow::bail!(
374 "Failed to publish {}@{}: HTTP {} - {}",
375 artifact_id,
376 publish.version,
377 status,
378 body
379 );
380 }
381 } else {
382 let payload = json!({
384 "artifactId": artifact_id,
385 "artifactType": artifact_type,
386 "name": &publish.name,
387 "description": publish.description.as_deref().unwrap_or(""),
388 "labels": publish.labels,
389 "firstVersion": {
390 "version": publish.version,
391 "content": {
392 "content": content,
393 "contentType": content_type,
394 "references": references
395 },
396 "name": &publish.name,
397 "description": publish.description.as_deref().unwrap_or(""),
398 "labels": {}
399 }
400 });
401
402 let if_exists_param = match publish.if_exists {
404 IfExistsAction::Fail => "FAIL",
405 IfExistsAction::CreateVersion => "CREATE_VERSION",
406 IfExistsAction::FindOrCreateVersion => "FIND_OR_CREATE_VERSION",
407 };
408
409 let url = format!(
411 "{}/apis/registry/v3/groups/{}/artifacts?ifExists={}",
412 self.base_url.trim_end_matches('/'),
413 group_id,
414 if_exists_param
415 );
416
417 let response = self
418 .client
419 .post(&url)
420 .header("Content-Type", "application/json")
421 .json(&payload)
422 .send()
423 .await?;
424
425 if response.status().is_success() {
426 println!(" ✅ Published {}@{}", artifact_id, publish.version);
427 Ok(())
428 } else {
429 let status = response.status();
430 let body = response
431 .text()
432 .await
433 .unwrap_or_else(|_| "Unknown error".to_string());
434 anyhow::bail!(
435 "Failed to publish {}@{}: HTTP {} - {}",
436 artifact_id,
437 publish.version,
438 status,
439 body
440 );
441 }
442 }
443 }
444
445 pub async fn version_exists(
447 &self,
448 group_id: &str,
449 artifact_id: &str,
450 version: &str,
451 ) -> Result<bool> {
452 let url = format!(
453 "{}/apis/registry/v3/groups/{}/artifacts/{}/versions/{}",
454 self.base_url, group_id, artifact_id, version
455 );
456
457 match self.client.get(&url).send().await {
458 Ok(resp) => Ok(resp.status().is_success()),
459 Err(_) => Ok(false),
460 }
461 }
462
463 pub async fn get_version_content(
465 &self,
466 group_id: &str,
467 artifact_id: &str,
468 version: &str,
469 ) -> Result<String> {
470 let url = format!(
471 "{}/apis/registry/v3/groups/{}/artifacts/{}/versions/{}/content",
472 self.base_url, group_id, artifact_id, version
473 );
474 let resp = self.client.get(&url).send().await?.error_for_status()?;
475 Ok(resp.text().await?)
476 }
477
478 pub async fn get_system_info(&self) -> Result<SystemInfo> {
480 let url = format!("{}/apis/registry/v3/system/info", self.base_url);
481 let resp = self.client.get(&url).send().await?.error_for_status()?;
482 let system_info: SystemInfo = resp.json().await?;
483 Ok(system_info)
484 }
485
486 pub async fn get_version_metadata(
488 &self,
489 group_id: &str,
490 artifact_id: &str,
491 version: &semver::Version,
492 ) -> Result<ArtifactVersionMetadata> {
493 let url = format!(
494 "{}/apis/registry/v3/groups/{}/artifacts/{}/versions/{}",
495 self.base_url, group_id, artifact_id, version
496 );
497 let resp = self.client.get(&url).send().await?.error_for_status()?;
498 let metadata: ArtifactVersionMetadata = resp.json().await?;
499 Ok(metadata)
500 }
501
502 pub async fn get_version_references(
504 &self,
505 group_id: &str,
506 artifact_id: &str,
507 version: &semver::Version,
508 ref_type: Option<ReferenceType>,
509 ) -> Result<Vec<ArtifactVersionReference>> {
510 let url = format!(
511 "{}/apis/registry/v3/groups/{}/artifacts/{}/versions/{}/references",
512 self.base_url, group_id, artifact_id, version
513 );
514
515 let mut request = self.client.get(&url);
516 if let Some(ref_type) = ref_type {
519 request = request.query(&[("refType", ref_type.as_str())]);
520 }
521
522 let resp = request.send().await?.error_for_status()?;
523 let references: Vec<ArtifactVersionReference> = resp.json().await?;
524 Ok(references)
525 }
526}