deps_npm/
registry.rs

1//! npm registry client.
2//!
3//! Provides access to the npm registry via:
4//! - Package metadata API (<https://registry.npmjs.org/{package}>) for version lookups
5//! - Search API (<https://registry.npmjs.org/-/v1/search>) for package search
6//!
7//! All HTTP requests are cached aggressively using ETag/Last-Modified headers.
8
9use crate::types::{NpmPackage, NpmVersion};
10use deps_core::{DepsError, HttpCache, Result};
11use serde::Deserialize;
12use std::any::Any;
13use std::sync::Arc;
14
15const REGISTRY_BASE: &str = "https://registry.npmjs.org";
16
17/// Base URL for package pages on npmjs.com
18pub const NPMJS_URL: &str = "https://www.npmjs.com/package";
19
20/// Returns the URL for a package's page on npmjs.com.
21///
22/// Package names are URL-encoded to prevent path traversal attacks.
23pub fn package_url(name: &str) -> String {
24    format!("{}/{}", NPMJS_URL, urlencoding::encode(name))
25}
26
27/// Client for interacting with the npm registry.
28///
29/// Uses the npm registry API for package metadata and search.
30/// All requests are cached via the provided HttpCache.
31#[derive(Clone)]
32pub struct NpmRegistry {
33    cache: Arc<HttpCache>,
34}
35
36impl NpmRegistry {
37    /// Creates a new npm registry client with the given HTTP cache.
38    pub const fn new(cache: Arc<HttpCache>) -> Self {
39        Self { cache }
40    }
41
42    /// Fetches all versions for a package from the npm registry.
43    ///
44    /// Returns versions sorted newest-first. Includes deprecated versions.
45    ///
46    /// # Errors
47    ///
48    /// Returns an error if:
49    /// - HTTP request fails
50    /// - Response body is invalid UTF-8
51    /// - JSON parsing fails
52    /// - Package does not exist
53    ///
54    /// # Examples
55    ///
56    /// ```no_run
57    /// # use deps_npm::NpmRegistry;
58    /// # use deps_core::HttpCache;
59    /// # use std::sync::Arc;
60    /// # #[tokio::main]
61    /// # async fn main() {
62    /// let cache = Arc::new(HttpCache::new());
63    /// let registry = NpmRegistry::new(cache);
64    ///
65    /// let versions = registry.get_versions("express").await.unwrap();
66    /// assert!(!versions.is_empty());
67    /// # }
68    /// ```
69    pub async fn get_versions(&self, name: &str) -> Result<Vec<NpmVersion>> {
70        let url = format!("{REGISTRY_BASE}/{name}");
71        let data = self.cache.get_cached(&url).await?;
72
73        parse_package_metadata(&data)
74    }
75
76    /// Finds the latest version matching the given npm semver requirement.
77    ///
78    /// Only returns non-deprecated versions.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if:
83    /// - HTTP request fails
84    /// - Package does not exist
85    ///
86    /// # Examples
87    ///
88    /// ```no_run
89    /// # use deps_npm::NpmRegistry;
90    /// # use deps_core::HttpCache;
91    /// # use std::sync::Arc;
92    /// # #[tokio::main]
93    /// # async fn main() {
94    /// let cache = Arc::new(HttpCache::new());
95    /// let registry = NpmRegistry::new(cache);
96    ///
97    /// let latest = registry.get_latest_matching("express", "^4.0.0").await.unwrap();
98    /// assert!(latest.is_some());
99    /// # }
100    /// ```
101    pub async fn get_latest_matching(
102        &self,
103        name: &str,
104        req_str: &str,
105    ) -> Result<Option<NpmVersion>> {
106        let versions = self.get_versions(name).await?;
107
108        // Parse npm semver requirement
109        let req = node_semver::Range::parse(req_str)
110            .map_err(|e| DepsError::InvalidVersionReq(e.to_string()))?;
111
112        Ok(versions.into_iter().find(|v| {
113            let version = node_semver::Version::parse(&v.version).ok();
114            version.is_some_and(|ver| req.satisfies(&ver) && !v.deprecated)
115        }))
116    }
117
118    /// Searches for packages by name/keywords.
119    ///
120    /// Returns up to `limit` results sorted by relevance.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if:
125    /// - HTTP request fails
126    /// - JSON parsing fails
127    ///
128    /// # Examples
129    ///
130    /// ```no_run
131    /// # use deps_npm::NpmRegistry;
132    /// # use deps_core::HttpCache;
133    /// # use std::sync::Arc;
134    /// # #[tokio::main]
135    /// # async fn main() {
136    /// let cache = Arc::new(HttpCache::new());
137    /// let registry = NpmRegistry::new(cache);
138    ///
139    /// let results = registry.search("express", 10).await.unwrap();
140    /// assert!(!results.is_empty());
141    /// # }
142    /// ```
143    pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<NpmPackage>> {
144        let url = format!(
145            "{}/-/v1/search?text={}&size={}",
146            REGISTRY_BASE,
147            urlencoding::encode(query),
148            limit
149        );
150
151        let data = self.cache.get_cached(&url).await?;
152        parse_search_response(&data)
153    }
154}
155
156/// Package metadata response from npm registry.
157#[derive(Deserialize)]
158struct PackageMetadata {
159    versions: std::collections::HashMap<String, VersionMetadata>,
160}
161
162/// Version metadata from npm registry.
163#[derive(Deserialize)]
164struct VersionMetadata {
165    #[serde(default)]
166    deprecated: Option<String>,
167}
168
169/// Parses JSON response from npm package metadata API.
170fn parse_package_metadata(data: &[u8]) -> Result<Vec<NpmVersion>> {
171    let metadata: PackageMetadata = serde_json::from_slice(data)?;
172
173    // Parse versions once and cache the parsed Version for sorting
174    let mut versions_with_parsed: Vec<(NpmVersion, node_semver::Version)> = metadata
175        .versions
176        .into_iter()
177        .filter_map(|(version, meta)| {
178            let parsed = node_semver::Version::parse(&version).ok()?;
179            Some((
180                NpmVersion {
181                    version,
182                    deprecated: meta.deprecated.is_some(),
183                },
184                parsed,
185            ))
186        })
187        .collect();
188
189    // Sort using already-parsed versions (newest first)
190    versions_with_parsed.sort_unstable_by(|a, b| b.1.cmp(&a.1));
191
192    // Extract sorted versions
193    Ok(versions_with_parsed.into_iter().map(|(v, _)| v).collect())
194}
195
196/// Search response from npm registry.
197#[derive(Deserialize)]
198struct SearchResponse {
199    objects: Vec<SearchObject>,
200}
201
202/// Search result object.
203#[derive(Deserialize)]
204struct SearchObject {
205    package: SearchPackage,
206}
207
208/// Package information in search result.
209#[derive(Deserialize)]
210struct SearchPackage {
211    name: String,
212    #[serde(default)]
213    description: Option<String>,
214    #[serde(default)]
215    links: Option<PackageLinks>,
216    version: String,
217}
218
219/// Package links in search result.
220#[derive(Deserialize)]
221struct PackageLinks {
222    #[serde(default)]
223    homepage: Option<String>,
224    #[serde(default)]
225    repository: Option<String>,
226}
227
228/// Parses JSON response from npm search API.
229fn parse_search_response(data: &[u8]) -> Result<Vec<NpmPackage>> {
230    let response: SearchResponse = serde_json::from_slice(data)?;
231
232    Ok(response
233        .objects
234        .into_iter()
235        .map(|obj| {
236            let pkg = obj.package;
237            NpmPackage {
238                name: pkg.name,
239                description: pkg.description,
240                homepage: pkg.links.as_ref().and_then(|l| l.homepage.clone()),
241                repository: pkg.links.as_ref().and_then(|l| l.repository.clone()),
242                latest_version: pkg.version,
243            }
244        })
245        .collect())
246}
247
248// Implement PackageRegistry trait for NpmRegistry
249#[async_trait::async_trait]
250impl deps_core::PackageRegistry for NpmRegistry {
251    type Version = NpmVersion;
252    type Metadata = NpmPackage;
253    type VersionReq = node_semver::Range;
254
255    async fn get_versions(&self, name: &str) -> Result<Vec<Self::Version>> {
256        self.get_versions(name).await
257    }
258
259    async fn get_latest_matching(
260        &self,
261        name: &str,
262        req: &Self::VersionReq,
263    ) -> Result<Option<Self::Version>> {
264        self.get_latest_matching(name, &req.to_string()).await
265    }
266
267    async fn search(&self, query: &str, limit: usize) -> Result<Vec<Self::Metadata>> {
268        self.search(query, limit).await
269    }
270}
271
272// Implement Registry trait for NpmRegistry
273#[async_trait::async_trait]
274impl deps_core::Registry for NpmRegistry {
275    async fn get_versions(&self, name: &str) -> Result<Vec<Box<dyn deps_core::Version>>> {
276        let versions = self.get_versions(name).await?;
277        Ok(versions
278            .into_iter()
279            .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
280            .collect())
281    }
282
283    async fn get_latest_matching(
284        &self,
285        name: &str,
286        req: &str,
287    ) -> Result<Option<Box<dyn deps_core::Version>>> {
288        let version = self.get_latest_matching(name, req).await?;
289        Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
290    }
291
292    async fn search(&self, query: &str, limit: usize) -> Result<Vec<Box<dyn deps_core::Metadata>>> {
293        let packages = self.search(query, limit).await?;
294        Ok(packages
295            .into_iter()
296            .map(|p| Box::new(p) as Box<dyn deps_core::Metadata>)
297            .collect())
298    }
299
300    fn package_url(&self, name: &str) -> String {
301        package_url(name)
302    }
303
304    fn as_any(&self) -> &dyn Any {
305        self
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_parse_package_metadata() {
315        let json = r#"{
316  "versions": {
317    "1.0.0": {},
318    "1.0.1": {"deprecated": "Use 1.0.2 instead"},
319    "1.0.2": {}
320  },
321  "dist-tags": {
322    "latest": "1.0.2"
323  }
324}"#;
325
326        let versions = parse_package_metadata(json.as_bytes()).unwrap();
327        assert_eq!(versions.len(), 3);
328
329        // Sorted newest first
330        assert_eq!(versions[0].version, "1.0.2");
331        assert!(!versions[0].deprecated);
332
333        assert_eq!(versions[1].version, "1.0.1");
334        assert!(versions[1].deprecated);
335
336        assert_eq!(versions[2].version, "1.0.0");
337        assert!(!versions[2].deprecated);
338    }
339
340    #[test]
341    fn test_parse_search_response() {
342        let json = r#"{
343  "objects": [
344    {
345      "package": {
346        "name": "express",
347        "description": "Fast, unopinionated web framework",
348        "version": "4.18.2",
349        "links": {
350          "homepage": "http://expressjs.com/",
351          "repository": "https://github.com/expressjs/express"
352        }
353      }
354    }
355  ]
356}"#;
357
358        let packages = parse_search_response(json.as_bytes()).unwrap();
359        assert_eq!(packages.len(), 1);
360
361        let pkg = &packages[0];
362        assert_eq!(pkg.name, "express");
363        assert_eq!(
364            pkg.description,
365            Some("Fast, unopinionated web framework".into())
366        );
367        assert_eq!(pkg.latest_version, "4.18.2");
368        assert_eq!(pkg.homepage, Some("http://expressjs.com/".into()));
369    }
370
371    #[test]
372    fn test_parse_search_response_minimal() {
373        let json = r#"{
374  "objects": [
375    {
376      "package": {
377        "name": "minimal-pkg",
378        "version": "1.0.0"
379      }
380    }
381  ]
382}"#;
383
384        let packages = parse_search_response(json.as_bytes()).unwrap();
385        assert_eq!(packages.len(), 1);
386        assert_eq!(packages[0].name, "minimal-pkg");
387        assert_eq!(packages[0].description, None);
388    }
389
390    #[tokio::test]
391    #[ignore]
392    async fn test_fetch_real_express_versions() {
393        let cache = Arc::new(HttpCache::new());
394        let registry = NpmRegistry::new(cache);
395        let versions = registry.get_versions("express").await.unwrap();
396
397        assert!(!versions.is_empty());
398        assert!(versions.iter().any(|v| v.version.starts_with("4.")));
399    }
400
401    #[tokio::test]
402    #[ignore]
403    async fn test_search_real() {
404        let cache = Arc::new(HttpCache::new());
405        let registry = NpmRegistry::new(cache);
406        let results = registry.search("express", 5).await.unwrap();
407
408        assert!(!results.is_empty());
409        assert!(results.iter().any(|r| r.name == "express"));
410    }
411
412    #[tokio::test]
413    #[ignore]
414    async fn test_get_latest_matching_real() {
415        let cache = Arc::new(HttpCache::new());
416        let registry = NpmRegistry::new(cache);
417        let latest = registry
418            .get_latest_matching("express", "^4.0.0")
419            .await
420            .unwrap();
421
422        assert!(latest.is_some());
423        let version = latest.unwrap();
424        assert!(version.version.starts_with("4."));
425        assert!(!version.deprecated);
426    }
427}