Skip to main content

nika_engine/registry/
api.rs

1//! SuperNovae Registry API Client
2//!
3//! HTTP client for the SuperNovae package registry.
4//!
5//! # Architecture
6//!
7//! ```text
8//! ┌─────────────────────────────────────────────────────────────────────────────┐
9//! │  REGISTRY API CLIENT                                                        │
10//! ├─────────────────────────────────────────────────────────────────────────────┤
11//! │                                                                             │
12//! │  RegistryClient                                                             │
13//! │  ├── base_url: https://registry.supernovae.studio/api/v1                    │
14//! │  ├── client: reqwest::Client (connection pooling, rustls)                   │
15//! │  └── timeout: 30s default                                                   │
16//! │                                                                             │
17//! │  API Endpoints:                                                             │
18//! │  GET  /packages/:name              → Package metadata                       │
19//! │  GET  /packages/:name/versions     → All versions                           │
20//! │  GET  /packages/:name/:version     → Specific version                       │
21//! │  GET  /search?q=:query             → Search packages                        │
22//! │  GET  /packages/:name/:version/download → Download tarball                  │
23//! │                                                                             │
24//! └─────────────────────────────────────────────────────────────────────────────┘
25//! ```
26//!
27//! # Usage
28//!
29//! ```rust,ignore
30//! use nika::registry::api::RegistryClient;
31//!
32//! let client = RegistryClient::new();
33//!
34//! // Search for packages
35//! let results = client.search("workflow").await?;
36//!
37//! // Get package info
38//! let info = client.get_package("@nika/core").await?;
39//!
40//! // Download package
41//! let bytes = client.download("@nika/core", "1.0.0").await?;
42//! ```
43
44use std::path::PathBuf;
45use std::time::Duration;
46
47use reqwest::Client;
48use serde::{Deserialize, Serialize};
49use thiserror::Error;
50
51/// Default registry URL
52pub const DEFAULT_REGISTRY_URL: &str = "https://registry.supernovae.studio/api/v1";
53
54/// Environment variable to override registry URL
55pub const REGISTRY_URL_ENV: &str = "NIKA_REGISTRY_URL";
56
57/// Default request timeout in seconds
58pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
59
60/// Errors that can occur during registry API operations.
61#[derive(Error, Debug)]
62pub enum RegistryApiError {
63    #[error("Network error: {0}")]
64    NetworkError(#[from] reqwest::Error),
65
66    #[error("Package not found: {0}")]
67    PackageNotFound(String),
68
69    #[error("Version not found: {0}@{1}")]
70    VersionNotFound(String, String),
71
72    #[error("API error: {status} - {message}")]
73    ApiError { status: u16, message: String },
74
75    #[error("Invalid response: {0}")]
76    InvalidResponse(String),
77
78    #[error("IO error: {0}")]
79    IoError(#[from] std::io::Error),
80
81    #[error("Rate limited: retry after {retry_after} seconds")]
82    RateLimited { retry_after: u64 },
83}
84
85/// Package metadata from the registry.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct PackageInfo {
88    /// Package name (e.g., "@nika/core")
89    pub name: String,
90
91    /// Latest version
92    pub latest_version: String,
93
94    /// Short description
95    #[serde(default)]
96    pub description: Option<String>,
97
98    /// Package authors
99    #[serde(default)]
100    pub authors: Option<Vec<String>>,
101
102    /// SPDX license
103    #[serde(default)]
104    pub license: Option<String>,
105
106    /// Repository URL
107    #[serde(default)]
108    pub repository: Option<String>,
109
110    /// Keywords for search
111    #[serde(default)]
112    pub keywords: Option<Vec<String>>,
113
114    /// Download count (all versions)
115    #[serde(default)]
116    pub downloads: Option<u64>,
117
118    /// Available versions (newest first)
119    #[serde(default)]
120    pub versions: Vec<String>,
121
122    /// Created timestamp (ISO 8601)
123    #[serde(default)]
124    pub created_at: Option<String>,
125
126    /// Last updated timestamp (ISO 8601)
127    #[serde(default)]
128    pub updated_at: Option<String>,
129}
130
131/// Version-specific metadata from the registry.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct VersionInfo {
134    /// Package name
135    pub name: String,
136
137    /// Version string
138    pub version: String,
139
140    /// Description
141    #[serde(default)]
142    pub description: Option<String>,
143
144    /// Dependencies (name -> version constraint)
145    #[serde(default)]
146    pub dependencies: Option<std::collections::HashMap<String, String>>,
147
148    /// Skills provided by this version
149    #[serde(default)]
150    pub skills: Option<Vec<SkillInfo>>,
151
152    /// Tarball size in bytes
153    #[serde(default)]
154    pub size: Option<u64>,
155
156    /// SHA256 checksum of tarball
157    #[serde(default)]
158    pub checksum: Option<String>,
159
160    /// Published timestamp (ISO 8601)
161    #[serde(default)]
162    pub published_at: Option<String>,
163}
164
165/// Skill information within a package.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct SkillInfo {
168    /// Skill name/alias
169    pub name: String,
170
171    /// Relative path within package
172    pub path: String,
173
174    /// Description
175    #[serde(default)]
176    pub description: Option<String>,
177}
178
179/// Search result item.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct SearchResult {
182    /// Package name
183    pub name: String,
184
185    /// Latest version
186    pub version: String,
187
188    /// Description
189    #[serde(default)]
190    pub description: Option<String>,
191
192    /// Keywords
193    #[serde(default)]
194    pub keywords: Option<Vec<String>>,
195
196    /// Download count
197    #[serde(default)]
198    pub downloads: Option<u64>,
199
200    /// Search relevance score (0.0 - 1.0)
201    #[serde(default)]
202    pub score: Option<f64>,
203}
204
205/// Search response from the registry.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct SearchResponse {
208    /// Total matching packages
209    pub total: usize,
210
211    /// Current page
212    pub page: usize,
213
214    /// Items per page
215    pub per_page: usize,
216
217    /// Search results
218    pub results: Vec<SearchResult>,
219}
220
221/// SuperNovae Registry API Client.
222///
223/// Thread-safe, connection-pooled HTTP client for the package registry.
224#[derive(Debug, Clone)]
225pub struct RegistryClient {
226    client: Client,
227    base_url: String,
228}
229
230impl RegistryClient {
231    /// Create a new registry client with default settings.
232    ///
233    /// Uses `NIKA_REGISTRY_URL` env var or falls back to the default registry.
234    ///
235    /// Returns an error if the HTTP client cannot be built (e.g., TLS init failure).
236    pub fn new() -> Result<Self, RegistryApiError> {
237        let base_url =
238            std::env::var(REGISTRY_URL_ENV).unwrap_or_else(|_| DEFAULT_REGISTRY_URL.to_string());
239
240        let client = Client::builder()
241            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
242            .user_agent(format!("nika/{}", env!("CARGO_PKG_VERSION")))
243            .build()?;
244
245        Ok(Self { client, base_url })
246    }
247
248    /// Create a client with a custom base URL.
249    ///
250    /// Returns an error if the HTTP client cannot be built.
251    pub fn with_url(base_url: impl Into<String>) -> Result<Self, RegistryApiError> {
252        let client = Client::builder()
253            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
254            .user_agent(format!("nika/{}", env!("CARGO_PKG_VERSION")))
255            .build()?;
256
257        Ok(Self {
258            client,
259            base_url: base_url.into(),
260        })
261    }
262
263    /// Create a client with custom timeout.
264    ///
265    /// Returns an error if the HTTP client cannot be built.
266    pub fn with_timeout(timeout_secs: u64) -> Result<Self, RegistryApiError> {
267        let base_url =
268            std::env::var(REGISTRY_URL_ENV).unwrap_or_else(|_| DEFAULT_REGISTRY_URL.to_string());
269
270        let client = Client::builder()
271            .timeout(Duration::from_secs(timeout_secs))
272            .user_agent(format!("nika/{}", env!("CARGO_PKG_VERSION")))
273            .build()?;
274
275        Ok(Self { client, base_url })
276    }
277
278    /// Get package metadata.
279    ///
280    /// # Arguments
281    ///
282    /// * `name` - Package name (e.g., "@nika/core")
283    ///
284    /// # Example
285    ///
286    /// ```rust,ignore
287    /// let client = RegistryClient::new();
288    /// let info = client.get_package("@nika/core").await?;
289    /// println!("Latest version: {}", info.latest_version);
290    /// ```
291    pub async fn get_package(&self, name: &str) -> Result<PackageInfo, RegistryApiError> {
292        let url = format!("{}/packages/{}", self.base_url, encode_package_name(name));
293
294        let response = self.client.get(&url).send().await?;
295
296        match response.status().as_u16() {
297            200 => response
298                .json::<PackageInfo>()
299                .await
300                .map_err(|e| RegistryApiError::InvalidResponse(e.to_string())),
301            404 => Err(RegistryApiError::PackageNotFound(name.to_string())),
302            429 => {
303                let retry_after = response
304                    .headers()
305                    .get("retry-after")
306                    .and_then(|v| v.to_str().ok())
307                    .and_then(|v| v.parse().ok())
308                    .unwrap_or(60);
309                Err(RegistryApiError::RateLimited { retry_after })
310            }
311            status => {
312                let message = response.text().await.unwrap_or_default();
313                Err(RegistryApiError::ApiError { status, message })
314            }
315        }
316    }
317
318    /// Get specific version metadata.
319    ///
320    /// # Arguments
321    ///
322    /// * `name` - Package name
323    /// * `version` - Version string (e.g., "1.0.0")
324    pub async fn get_version(
325        &self,
326        name: &str,
327        version: &str,
328    ) -> Result<VersionInfo, RegistryApiError> {
329        let url = format!(
330            "{}/packages/{}/{}",
331            self.base_url,
332            encode_package_name(name),
333            version
334        );
335
336        let response = self.client.get(&url).send().await?;
337
338        match response.status().as_u16() {
339            200 => response
340                .json::<VersionInfo>()
341                .await
342                .map_err(|e| RegistryApiError::InvalidResponse(e.to_string())),
343            404 => Err(RegistryApiError::VersionNotFound(
344                name.to_string(),
345                version.to_string(),
346            )),
347            429 => {
348                let retry_after = response
349                    .headers()
350                    .get("retry-after")
351                    .and_then(|v| v.to_str().ok())
352                    .and_then(|v| v.parse().ok())
353                    .unwrap_or(60);
354                Err(RegistryApiError::RateLimited { retry_after })
355            }
356            status => {
357                let message = response.text().await.unwrap_or_default();
358                Err(RegistryApiError::ApiError { status, message })
359            }
360        }
361    }
362
363    /// Get all available versions for a package.
364    ///
365    /// # Arguments
366    ///
367    /// * `name` - Package name
368    ///
369    /// # Returns
370    ///
371    /// Vector of version strings, newest first.
372    pub async fn get_versions(&self, name: &str) -> Result<Vec<String>, RegistryApiError> {
373        let url = format!(
374            "{}/packages/{}/versions",
375            self.base_url,
376            encode_package_name(name)
377        );
378
379        let response = self.client.get(&url).send().await?;
380
381        match response.status().as_u16() {
382            200 => {
383                #[derive(Deserialize)]
384                struct VersionsResponse {
385                    versions: Vec<String>,
386                }
387                let resp: VersionsResponse = response
388                    .json()
389                    .await
390                    .map_err(|e| RegistryApiError::InvalidResponse(e.to_string()))?;
391                Ok(resp.versions)
392            }
393            404 => Err(RegistryApiError::PackageNotFound(name.to_string())),
394            429 => {
395                let retry_after = 60;
396                Err(RegistryApiError::RateLimited { retry_after })
397            }
398            status => {
399                let message = response.text().await.unwrap_or_default();
400                Err(RegistryApiError::ApiError { status, message })
401            }
402        }
403    }
404
405    /// Search for packages.
406    ///
407    /// # Arguments
408    ///
409    /// * `query` - Search query
410    /// * `page` - Page number (1-indexed)
411    /// * `per_page` - Results per page (default 20, max 100)
412    ///
413    /// # Example
414    ///
415    /// ```rust,ignore
416    /// let client = RegistryClient::new();
417    /// let results = client.search("workflow", 1, 20).await?;
418    /// for pkg in results.results {
419    ///     println!("{}: {}", pkg.name, pkg.description.unwrap_or_default());
420    /// }
421    /// ```
422    pub async fn search(
423        &self,
424        query: &str,
425        page: usize,
426        per_page: usize,
427    ) -> Result<SearchResponse, RegistryApiError> {
428        let url = format!(
429            "{}/search?q={}&page={}&per_page={}",
430            self.base_url,
431            urlencoding::encode(query),
432            page,
433            per_page.min(100)
434        );
435
436        let response = self.client.get(&url).send().await?;
437
438        match response.status().as_u16() {
439            200 => response
440                .json::<SearchResponse>()
441                .await
442                .map_err(|e| RegistryApiError::InvalidResponse(e.to_string())),
443            429 => {
444                let retry_after = 60;
445                Err(RegistryApiError::RateLimited { retry_after })
446            }
447            status => {
448                let message = response.text().await.unwrap_or_default();
449                Err(RegistryApiError::ApiError { status, message })
450            }
451        }
452    }
453
454    /// Download package tarball.
455    ///
456    /// # Arguments
457    ///
458    /// * `name` - Package name
459    /// * `version` - Version to download
460    ///
461    /// # Returns
462    ///
463    /// Raw bytes of the tarball (gzipped tar archive).
464    pub async fn download(&self, name: &str, version: &str) -> Result<Vec<u8>, RegistryApiError> {
465        let url = format!(
466            "{}/packages/{}/{}/download",
467            self.base_url,
468            encode_package_name(name),
469            version
470        );
471
472        let response = self.client.get(&url).send().await?;
473
474        match response.status().as_u16() {
475            200 => response
476                .bytes()
477                .await
478                .map(|b| b.to_vec())
479                .map_err(RegistryApiError::from),
480            404 => Err(RegistryApiError::VersionNotFound(
481                name.to_string(),
482                version.to_string(),
483            )),
484            429 => {
485                let retry_after = 60;
486                Err(RegistryApiError::RateLimited { retry_after })
487            }
488            status => {
489                let message = response.text().await.unwrap_or_default();
490                Err(RegistryApiError::ApiError { status, message })
491            }
492        }
493    }
494
495    /// Download and extract package to a directory.
496    ///
497    /// # Arguments
498    ///
499    /// * `name` - Package name
500    /// * `version` - Version to download
501    /// * `target_dir` - Directory to extract to
502    ///
503    /// # Returns
504    ///
505    /// Path to the extracted package directory.
506    pub async fn download_and_extract(
507        &self,
508        name: &str,
509        version: &str,
510        target_dir: &PathBuf,
511    ) -> Result<PathBuf, RegistryApiError> {
512        use flate2::read::GzDecoder;
513        use tar::Archive;
514
515        let bytes = self.download(name, version).await?;
516
517        // Create target directory
518        std::fs::create_dir_all(target_dir)?;
519
520        // Extract tarball
521        let gz = GzDecoder::new(bytes.as_slice());
522        let mut archive = Archive::new(gz);
523        archive.unpack(target_dir)?;
524
525        Ok(target_dir.clone())
526    }
527
528    /// Check if a package exists.
529    pub async fn package_exists(&self, name: &str) -> Result<bool, RegistryApiError> {
530        match self.get_package(name).await {
531            Ok(_) => Ok(true),
532            Err(RegistryApiError::PackageNotFound(_)) => Ok(false),
533            Err(e) => Err(e),
534        }
535    }
536
537    /// Check if a specific version exists.
538    pub async fn version_exists(
539        &self,
540        name: &str,
541        version: &str,
542    ) -> Result<bool, RegistryApiError> {
543        match self.get_version(name, version).await {
544            Ok(_) => Ok(true),
545            Err(RegistryApiError::VersionNotFound(_, _)) => Ok(false),
546            Err(e) => Err(e),
547        }
548    }
549
550    /// Get the base URL being used.
551    pub fn base_url(&self) -> &str {
552        &self.base_url
553    }
554}
555
556/// Encode package name for URL path.
557///
558/// Handles scoped packages: `@scope/name` -> `@scope%2Fname`
559fn encode_package_name(name: &str) -> String {
560    // URL encode the slash in scoped packages
561    name.replace('/', "%2F")
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn test_encode_package_name() {
570        assert_eq!(encode_package_name("@nika/core"), "@nika%2Fcore");
571        assert_eq!(encode_package_name("simple-pkg"), "simple-pkg");
572        assert_eq!(
573            encode_package_name("@workflows/seo-audit"),
574            "@workflows%2Fseo-audit"
575        );
576    }
577
578    #[test]
579    fn test_registry_client_default() {
580        let client = RegistryClient::new().unwrap();
581        // Should use default URL if env var not set
582        assert!(client.base_url.contains("registry") || client.base_url.contains("supernovae"));
583    }
584
585    #[test]
586    fn test_registry_client_with_url() {
587        let client = RegistryClient::with_url("https://custom.registry.local/api").unwrap();
588        assert_eq!(client.base_url, "https://custom.registry.local/api");
589    }
590
591    #[test]
592    fn test_package_info_deserialize() {
593        let json = r#"{
594            "name": "@nika/core",
595            "latest_version": "1.0.0",
596            "description": "Core skills",
597            "versions": ["1.0.0", "0.9.0"]
598        }"#;
599
600        let info: PackageInfo = serde_json::from_str(json).unwrap();
601        assert_eq!(info.name, "@nika/core");
602        assert_eq!(info.latest_version, "1.0.0");
603        assert_eq!(info.versions.len(), 2);
604    }
605
606    #[test]
607    fn test_version_info_deserialize() {
608        let json = r#"{
609            "name": "@nika/core",
610            "version": "1.0.0",
611            "description": "Core skills package",
612            "skills": [
613                {"name": "brainstorm", "path": "skills/brainstorm.md"}
614            ]
615        }"#;
616
617        let info: VersionInfo = serde_json::from_str(json).unwrap();
618        assert_eq!(info.name, "@nika/core");
619        assert_eq!(info.version, "1.0.0");
620        assert!(info.skills.is_some());
621        assert_eq!(info.skills.as_ref().unwrap().len(), 1);
622    }
623
624    #[test]
625    fn test_search_response_deserialize() {
626        let json = r#"{
627            "total": 42,
628            "page": 1,
629            "per_page": 20,
630            "results": [
631                {
632                    "name": "@nika/core",
633                    "version": "1.0.0",
634                    "description": "Core package",
635                    "score": 0.95
636                }
637            ]
638        }"#;
639
640        let response: SearchResponse = serde_json::from_str(json).unwrap();
641        assert_eq!(response.total, 42);
642        assert_eq!(response.results.len(), 1);
643        assert_eq!(response.results[0].name, "@nika/core");
644    }
645
646    #[test]
647    fn test_skill_info_deserialize() {
648        let json = r#"{
649            "name": "brainstorm",
650            "path": "skills/brainstorm.skill.md",
651            "description": "Collaborative ideation"
652        }"#;
653
654        let skill: SkillInfo = serde_json::from_str(json).unwrap();
655        assert_eq!(skill.name, "brainstorm");
656        assert_eq!(skill.path, "skills/brainstorm.skill.md");
657    }
658
659    #[test]
660    fn test_registry_api_error_display() {
661        let err = RegistryApiError::PackageNotFound("@test/pkg".to_string());
662        assert_eq!(err.to_string(), "Package not found: @test/pkg");
663
664        let err = RegistryApiError::VersionNotFound("@test/pkg".to_string(), "1.0.0".to_string());
665        assert_eq!(err.to_string(), "Version not found: @test/pkg@1.0.0");
666
667        let err = RegistryApiError::RateLimited { retry_after: 60 };
668        assert_eq!(err.to_string(), "Rate limited: retry after 60 seconds");
669    }
670
671    #[test]
672    fn test_package_info_optional_fields() {
673        let json = r#"{
674            "name": "@minimal/pkg",
675            "latest_version": "0.1.0",
676            "versions": []
677        }"#;
678
679        let info: PackageInfo = serde_json::from_str(json).unwrap();
680        assert!(info.description.is_none());
681        assert!(info.authors.is_none());
682        assert!(info.license.is_none());
683        assert!(info.downloads.is_none());
684    }
685}