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
44pub struct RegistryClient {
45    #[allow(dead_code)]
46    pub name: String,
47    pub base_url: String,
48    pub client: Client,
49}
50
51impl RegistryClient {
52    pub fn new(cfg: &RegistryConfig) -> Result<Self> {
53        let mut headers = HeaderMap::new();
54        match &cfg.auth {
55            AuthConfig::None => {}
56            AuthConfig::Basic {
57                username,
58                password_env,
59            } => {
60                let pw = env::var(password_env)?;
61                let token = base64::encode_config(format!("{username}:{pw}"), base64::STANDARD);
62                let hv = HeaderValue::from_str(&format!("Basic {token}"))?;
63                headers.insert(AUTHORIZATION, hv);
64            }
65            AuthConfig::Token { token_env } => {
66                let tok = env::var(token_env)?;
67                let hv = HeaderValue::from_str(&tok)?;
68                headers.insert(AUTHORIZATION, hv);
69            }
70            AuthConfig::Bearer { token_env } => {
71                let tok = env::var(token_env)?;
72                let hv = HeaderValue::from_str(&format!("Bearer {tok}"))?;
73                headers.insert(AUTHORIZATION, hv);
74            }
75        }
76
77        let client = Client::builder().default_headers(headers).build()?;
78        Ok(RegistryClient {
79            name: cfg.name.clone(),
80            base_url: cfg.url.clone(),
81            client,
82        })
83    }
84
85    /// List all published versions for a given artifact
86    pub async fn list_versions(&self, group_id: &str, artifact_id: &str) -> Result<Vec<Version>> {
87        let url = format!(
88            "{}/apis/registry/v3/groups/{}/artifacts/{}/versions",
89            self.base_url, group_id, artifact_id
90        );
91        let resp = self.client.get(&url).send().await?.error_for_status()?;
92        #[derive(Deserialize)]
93        struct ApiResponse {
94            #[allow(dead_code)]
95            count: usize,
96            versions: Vec<ApiVersion>,
97        }
98
99        #[derive(Deserialize)]
100        struct ApiVersion {
101            version: String,
102        }
103
104        let api_response: ApiResponse = resp.json().await?;
105        let mut semver_versions = Vec::new();
106        for v in api_response.versions {
107            if let Ok(parsed) = Version::parse(&v.version) {
108                semver_versions.push(parsed);
109            }
110        }
111        Ok(semver_versions)
112    }
113
114    pub fn get_download_url(&self, group_id: &str, artifact_id: &str, version: &Version) -> String {
115        format!(
116            "{}/apis/registry/v3/groups/{}/artifacts/{}/versions/{}/content",
117            self.base_url, group_id, artifact_id, version
118        )
119    }
120
121    /// Download the raw content for a specific version
122    pub async fn download(
123        &self,
124        group_id: &str,
125        artifact_id: &str,
126        version: &Version,
127    ) -> Result<bytes::Bytes> {
128        let url = self.get_download_url(group_id, artifact_id, version);
129        let resp = self.client.get(&url).send().await?.error_for_status()?;
130        Ok(resp.bytes().await?)
131    }
132
133    /// List all groups in the registry
134    pub async fn list_groups(&self) -> Result<Vec<String>> {
135        let url = format!("{}/apis/registry/v3/groups", self.base_url);
136        let resp = self.client.get(&url).send().await?.error_for_status()?;
137
138        #[derive(Deserialize)]
139        struct ApiResponse {
140            #[allow(dead_code)]
141            count: usize,
142            groups: Vec<ApiGroup>,
143        }
144
145        #[derive(Deserialize)]
146        struct ApiGroup {
147            #[serde(rename = "groupId")]
148            group_id: String,
149        }
150
151        let api_response: ApiResponse = resp.json().await?;
152        Ok(api_response
153            .groups
154            .into_iter()
155            .map(|g| g.group_id)
156            .collect())
157    }
158
159    /// List all artifacts in a specific group
160    pub async fn list_artifacts(&self, group_id: &str) -> Result<Vec<String>> {
161        let url = format!(
162            "{}/apis/registry/v3/groups/{}/artifacts",
163            self.base_url, group_id
164        );
165        let resp = self.client.get(&url).send().await?.error_for_status()?;
166
167        #[derive(Deserialize)]
168        struct ApiResponse {
169            #[allow(dead_code)]
170            count: usize,
171            artifacts: Vec<ApiArtifact>,
172        }
173
174        #[derive(Deserialize)]
175        struct ApiArtifact {
176            #[serde(rename = "artifactId")]
177            artifact_id: String,
178        }
179
180        let api_response: ApiResponse = resp.json().await?;
181        Ok(api_response
182            .artifacts
183            .into_iter()
184            .map(|a| a.artifact_id)
185            .collect())
186    }
187
188    /// Check if an artifact exists in the registry
189    pub async fn artifact_exists(&self, group_id: &str, artifact_id: &str) -> Result<bool> {
190        let url = format!(
191            "{}/apis/registry/v3/groups/{}/artifacts/{}",
192            self.base_url, group_id, artifact_id
193        );
194
195        match self.client.get(&url).send().await {
196            Ok(resp) => Ok(resp.status().is_success()),
197            Err(_) => Ok(false),
198        }
199    }
200
201    /// Get artifact metadata including type
202    pub async fn get_artifact_metadata(
203        &self,
204        group_id: &str,
205        artifact_id: &str,
206    ) -> Result<ArtifactMetadata> {
207        let url = format!(
208            "{}/apis/registry/v3/groups/{}/artifacts/{}",
209            self.base_url, group_id, artifact_id
210        );
211        let resp = self.client.get(&url).send().await?.error_for_status()?;
212
213        let mut metadata: ArtifactMetadata = resp.json().await?;
214        // Ensure group_id is set even if not provided by the API response
215        if metadata.group_id.is_none() {
216            metadata.group_id = Some(group_id.to_string());
217        }
218        Ok(metadata)
219    }
220
221    /// Publish an artifact to the registry
222    pub async fn publish_artifact(&self, publish: &PublishConfig, content: &str) -> Result<()> {
223        let group_id = publish.resolved_group_id();
224        let artifact_id = publish.resolved_artifact_id();
225        let content_type = publish.resolved_content_type();
226        let artifact_type = publish.resolved_artifact_type();
227
228        // Check if the version already exists
229        if self
230            .version_exists(&group_id, &artifact_id, &publish.version)
231            .await?
232        {
233            // Version exists, compare content
234            match self
235                .get_version_content(&group_id, &artifact_id, &publish.version)
236                .await
237            {
238                Ok(existing_content) => {
239                    if existing_content.trim() == content.trim() {
240                        println!(
241                            "  ℹ️  Version {}@{} already published with identical content",
242                            artifact_id, publish.version
243                        );
244                        return Ok(());
245                    } else {
246                        // Content is different, suggest version bump
247                        println!(
248                            "  ⚠️  Version {}@{} already exists with different content",
249                            artifact_id, publish.version
250                        );
251                        println!(
252                            "     Consider bumping the version (e.g., {}) to publish the updated content",
253                            suggest_version_bump(&publish.version)
254                        );
255                        anyhow::bail!("Cannot publish different content with same version");
256                    }
257                }
258                Err(_) => {
259                    // Could not retrieve existing content, proceed with normal flow
260                    println!(
261                        "  ⚠️  Version {}@{} exists but content comparison failed, proceeding with publish",
262                        artifact_id, publish.version
263                    );
264                }
265            }
266        }
267
268        // Build references array for the API
269        let references: Vec<Value> = publish
270            .references
271            .iter()
272            .map(|r| {
273                json!({
274                    "groupId": r.resolved_group_id(),
275                    "artifactId": r.resolved_artifact_id(),
276                    "version": r.version,
277                    "name": r.name_alias.as_deref().unwrap_or(&r.resolved_artifact_id())
278                })
279            })
280            .collect();
281
282        // Check if artifact exists to determine which endpoint to use
283        let artifact_exists = self.artifact_exists(&group_id, &artifact_id).await?;
284
285        if artifact_exists {
286            // Artifact exists, create a new version using the versions endpoint
287            let version_payload = json!({
288                "version": publish.version,
289                "content": {
290                    "content": content,
291                    "contentType": content_type,
292                    "references": references
293                },
294                "name": &publish.name,
295                "description": publish.description.as_deref().unwrap_or(""),
296                "labels": {}
297            });
298
299            let url = format!(
300                "{}/apis/registry/v3/groups/{}/artifacts/{}/versions",
301                self.base_url.trim_end_matches('/'),
302                group_id,
303                artifact_id
304            );
305
306            let response = self
307                .client
308                .post(&url)
309                .header("Content-Type", "application/json")
310                .json(&version_payload)
311                .send()
312                .await?;
313
314            if response.status().is_success() {
315                println!("  ✅ Published {}@{}", artifact_id, publish.version);
316                Ok(())
317            } else {
318                let status = response.status();
319                let body = response
320                    .text()
321                    .await
322                    .unwrap_or_else(|_| "Unknown error".to_string());
323                anyhow::bail!(
324                    "Failed to publish {}@{}: HTTP {} - {}",
325                    artifact_id,
326                    publish.version,
327                    status,
328                    body
329                );
330            }
331        } else {
332            // Artifact doesn't exist, create new artifact with first version
333            let payload = json!({
334                "artifactId": artifact_id,
335                "artifactType": artifact_type,
336                "name": &publish.name,
337                "description": publish.description.as_deref().unwrap_or(""),
338                "labels": publish.labels,
339                "firstVersion": {
340                    "version": publish.version,
341                    "content": {
342                        "content": content,
343                        "contentType": content_type,
344                        "references": references
345                    },
346                    "name": &publish.name,
347                    "description": publish.description.as_deref().unwrap_or(""),
348                    "labels": {}
349                }
350            });
351
352            // Determine the ifExists parameter
353            let if_exists_param = match publish.if_exists {
354                IfExistsAction::Fail => "FAIL",
355                IfExistsAction::CreateVersion => "CREATE_VERSION",
356                IfExistsAction::FindOrCreateVersion => "FIND_OR_CREATE_VERSION",
357            };
358
359            // Make the HTTP request
360            let url = format!(
361                "{}/apis/registry/v3/groups/{}/artifacts?ifExists={}",
362                self.base_url.trim_end_matches('/'),
363                group_id,
364                if_exists_param
365            );
366
367            let response = self
368                .client
369                .post(&url)
370                .header("Content-Type", "application/json")
371                .json(&payload)
372                .send()
373                .await?;
374
375            if response.status().is_success() {
376                println!("  ✅ Published {}@{}", artifact_id, publish.version);
377                Ok(())
378            } else {
379                let status = response.status();
380                let body = response
381                    .text()
382                    .await
383                    .unwrap_or_else(|_| "Unknown error".to_string());
384                anyhow::bail!(
385                    "Failed to publish {}@{}: HTTP {} - {}",
386                    artifact_id,
387                    publish.version,
388                    status,
389                    body
390                );
391            }
392        }
393    }
394
395    /// Check if a specific artifact version exists
396    pub async fn version_exists(
397        &self,
398        group_id: &str,
399        artifact_id: &str,
400        version: &str,
401    ) -> Result<bool> {
402        let url = format!(
403            "{}/apis/registry/v3/groups/{}/artifacts/{}/versions/{}",
404            self.base_url, group_id, artifact_id, version
405        );
406
407        match self.client.get(&url).send().await {
408            Ok(resp) => Ok(resp.status().is_success()),
409            Err(_) => Ok(false),
410        }
411    }
412
413    /// Get the content of a specific artifact version as a string
414    pub async fn get_version_content(
415        &self,
416        group_id: &str,
417        artifact_id: &str,
418        version: &str,
419    ) -> Result<String> {
420        let url = format!(
421            "{}/apis/registry/v3/groups/{}/artifacts/{}/versions/{}/content",
422            self.base_url, group_id, artifact_id, version
423        );
424        let resp = self.client.get(&url).send().await?.error_for_status()?;
425        Ok(resp.text().await?)
426    }
427}