Skip to main content

update_kit/checker/sources/
npm_registry.rs

1use crate::errors::UpdateKitError;
2use crate::utils::http::{fetch_with_timeout, FetchOptions as HttpFetchOptions};
3
4use super::{
5    FetchOptions, FetchVersionsOptions, VersionInfo, VersionListResult, VersionSource,
6    VersionSourceResult,
7};
8
9/// A version source backed by the npm registry.
10pub struct NpmRegistrySource {
11    package_name: String,
12    registry_url: String,
13}
14
15impl NpmRegistrySource {
16    pub fn new(package_name: String, registry_url: Option<String>) -> Self {
17        Self {
18            package_name,
19            registry_url: registry_url
20                .unwrap_or_else(|| "https://registry.npmjs.org".to_string()),
21        }
22    }
23
24    fn build_headers(&self) -> Vec<(String, String)> {
25        vec![("Accept".to_string(), "application/json".to_string())]
26    }
27}
28
29#[async_trait::async_trait]
30impl VersionSource for NpmRegistrySource {
31    fn name(&self) -> &str {
32        "npm"
33    }
34
35    async fn fetch_latest(&self, _options: FetchOptions) -> VersionSourceResult {
36        let url = format!("{}/{}/latest", self.registry_url, self.package_name);
37
38        let response = match fetch_with_timeout(
39            &url,
40            Some(HttpFetchOptions {
41                timeout_ms: None,
42                headers: Some(self.build_headers()),
43            }),
44        )
45        .await
46        {
47            Ok(r) => r,
48            Err(e) => {
49                return VersionSourceResult::Error {
50                    reason: e.to_string(),
51                    status: None,
52                }
53            }
54        };
55
56        let status = response.status().as_u16();
57
58        if !response.status().is_success() {
59            return VersionSourceResult::Error {
60                reason: format!("npm registry returned status {}", status),
61                status: Some(status),
62            };
63        }
64
65        let json: serde_json::Value = match response.json().await {
66            Ok(j) => j,
67            Err(e) => {
68                return VersionSourceResult::Error {
69                    reason: format!("Failed to parse response: {}", e),
70                    status: Some(status),
71                }
72            }
73        };
74
75        let version = match json.get("version").and_then(|v| v.as_str()) {
76            Some(v) => v.to_string(),
77            None => {
78                return VersionSourceResult::Error {
79                    reason: "Missing 'version' field in response".into(),
80                    status: Some(status),
81                }
82            }
83        };
84
85        VersionSourceResult::Found {
86            info: VersionInfo {
87                version,
88                release_url: None,
89                release_notes: None,
90                assets: None,
91                published_at: None,
92            },
93            etag: None,
94        }
95    }
96
97    async fn fetch_versions(
98        &self,
99        options: FetchVersionsOptions,
100    ) -> Result<VersionListResult, UpdateKitError> {
101        let url = format!("{}/{}", self.registry_url, self.package_name);
102
103        let response = fetch_with_timeout(
104            &url,
105            Some(HttpFetchOptions {
106                timeout_ms: None,
107                headers: Some(self.build_headers()),
108            }),
109        )
110        .await?;
111
112        if !response.status().is_success() {
113            return Ok(VersionListResult::Error {
114                reason: format!("npm registry returned status {}", response.status().as_u16()),
115            });
116        }
117
118        let json: serde_json::Value = response.json().await?;
119
120        let versions_obj = match json.get("versions").and_then(|v| v.as_object()) {
121            Some(v) => v,
122            None => {
123                return Ok(VersionListResult::Error {
124                    reason: "Missing 'versions' object in response".into(),
125                });
126            }
127        };
128
129        let limit = options.limit.unwrap_or(usize::MAX);
130        let versions: Vec<VersionInfo> = versions_obj
131            .keys()
132            .rev()
133            .take(limit)
134            .map(|ver| VersionInfo {
135                version: ver.clone(),
136                release_url: None,
137                release_notes: None,
138                assets: None,
139                published_at: None,
140            })
141            .collect();
142
143        let total_count = Some(versions_obj.len());
144
145        Ok(VersionListResult::Success {
146            versions,
147            next_cursor: None,
148            total_count,
149        })
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn test_source_name() {
159        let source = NpmRegistrySource::new("my-pkg".into(), None);
160        assert_eq!(source.name(), "npm");
161    }
162
163    #[test]
164    fn test_default_registry_url() {
165        let source = NpmRegistrySource::new("my-pkg".into(), None);
166        assert_eq!(source.registry_url, "https://registry.npmjs.org");
167    }
168
169    #[test]
170    fn test_custom_registry_url() {
171        let source = NpmRegistrySource::new(
172            "my-pkg".into(),
173            Some("https://npm.example.com".into()),
174        );
175        assert_eq!(source.registry_url, "https://npm.example.com");
176    }
177
178    #[test]
179    fn build_headers_contains_accept() {
180        let source = NpmRegistrySource::new("my-pkg".into(), None);
181        let headers = source.build_headers();
182        assert!(headers
183            .iter()
184            .any(|(k, v)| k == "Accept" && v == "application/json"));
185    }
186
187    #[test]
188    fn scoped_package_name() {
189        let source = NpmRegistrySource::new("@scope/pkg".into(), None);
190        assert_eq!(source.package_name, "@scope/pkg");
191        assert_eq!(source.registry_url, "https://registry.npmjs.org");
192    }
193
194    #[tokio::test]
195    async fn fetch_latest_unreachable_returns_error() {
196        let source =
197            NpmRegistrySource::new("test-pkg".into(), Some("https://localhost:1".into()));
198        let result = source.fetch_latest(FetchOptions::default()).await;
199        match result {
200            VersionSourceResult::Error { reason, .. } => {
201                assert!(!reason.is_empty());
202            }
203            other => panic!("Expected Error, got: {other:?}"),
204        }
205    }
206
207    #[tokio::test]
208    async fn fetch_versions_unreachable_returns_error() {
209        let source =
210            NpmRegistrySource::new("test-pkg".into(), Some("https://localhost:1".into()));
211        let result = source.fetch_versions(FetchVersionsOptions::default()).await;
212        assert!(result.is_err());
213    }
214
215    #[test]
216    fn custom_registry_url_preserved() {
217        let source =
218            NpmRegistrySource::new("pkg".into(), Some("https://custom.registry.com".into()));
219        assert_eq!(source.registry_url, "https://custom.registry.com");
220    }
221
222    #[tokio::test]
223    async fn fetch_latest_http_url_rejected() {
224        let source =
225            NpmRegistrySource::new("test-pkg".into(), Some("http://insecure.com".into()));
226        let result = source.fetch_latest(FetchOptions::default()).await;
227        match result {
228            VersionSourceResult::Error { reason, .. } => {
229                assert!(
230                    reason.contains("HTTPS") || reason.contains("Insecure"),
231                    "Expected HTTPS-related error, got: {reason}"
232                );
233            }
234            other => panic!("Expected Error for HTTP URL, got: {other:?}"),
235        }
236    }
237
238    #[tokio::test]
239    async fn fetch_versions_http_url_rejected() {
240        let source =
241            NpmRegistrySource::new("test-pkg".into(), Some("http://insecure.com".into()));
242        let result = source.fetch_versions(FetchVersionsOptions::default()).await;
243        assert!(result.is_err());
244    }
245
246    #[test]
247    fn source_name_with_custom_registry() {
248        let source =
249            NpmRegistrySource::new("any-name".into(), Some("https://custom.com".into()));
250        assert_eq!(source.name(), "npm");
251    }
252}