Skip to main content

apicurio_cli/
registry.rs

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
12/// Suggest a version bump for a given version string
13fn suggest_version_bump(version: &str) -> String {
14    if let Ok(parsed_version) = Version::parse(version) {
15        // Bump patch version
16        let mut new_version = parsed_version.clone();
17        new_version.patch += 1;
18        new_version.to_string()
19    } else {
20        // If not a valid semver, try simple string manipulation
21        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    /// List all published versions for a given artifact
136    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    /// Download the raw content for a specific version
172    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    /// List all groups in the registry
184    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    /// List all artifacts in a specific group
210    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    /// Check if an artifact exists in the registry
239    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    /// Get artifact metadata including type
252    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        // Ensure group_id is set even if not provided by the API response
265        if metadata.group_id.is_none() {
266            metadata.group_id = Some(group_id.to_string());
267        }
268        Ok(metadata)
269    }
270
271    /// Publish an artifact to the registry
272    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        // Check if the version already exists
279        if self
280            .version_exists(&group_id, &artifact_id, &publish.version)
281            .await?
282        {
283            // Version exists, compare content
284            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                        // Content is different, suggest version bump
297                        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                    // Could not retrieve existing content, proceed with normal flow
310                    println!(
311                        "  ⚠️  Version {}@{} exists but content comparison failed, proceeding with publish",
312                        artifact_id, publish.version
313                    );
314                }
315            }
316        }
317
318        // Build references array for the API
319        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        // Check if artifact exists to determine which endpoint to use
333        let artifact_exists = self.artifact_exists(&group_id, &artifact_id).await?;
334
335        if artifact_exists {
336            // Artifact exists, create a new version using the versions endpoint
337            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            // Artifact doesn't exist, create new artifact with first version
383            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            // Determine the ifExists parameter
403            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            // Make the HTTP request
410            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    /// Check if a specific artifact version exists
446    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    /// Get the content of a specific artifact version as a string
464    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    /// Get system information from the registry
479    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    /// Get version metadata including references
487    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    /// Get artifact version references (outbound by default)
503    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        // Only add refType query parameter if explicitly specified
517        // The API defaults to OUTBOUND when not provided
518        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}