update_kit/checker/sources/
npm_registry.rs1use 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
9pub 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}