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