Skip to main content

ephemeris_release/adapters/npm/
registry.rs

1//! npm registry version lookup.
2
3use std::collections::BTreeMap;
4
5use ephemeris_token::Token;
6use facet::Facet;
7use facet_value::Value;
8use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
9use reqwest::Url;
10
11use crate::PackageRegistry;
12use crate::adapters::registry::{HttpRegistry, RegistryError};
13
14/// Characters that must not stay literal in a package name path segment.
15const NAME_SEGMENT: &AsciiSet = &CONTROLS.add(b'/').add(b'?').add(b'#').add(b'%').add(b' ');
16
17/// An npm registry answering `GET {registry}/{package}` with a packument.
18///
19/// A scoped name such as `@scope/name` is requested as `@scope%2Fname`. A 404
20/// means no version exists. Retries and the token handling follow
21/// [`CargoRegistry`](crate::CargoRegistry), with the token sent as
22/// `Authorization: Bearer <value>`.
23#[derive(Debug)]
24pub struct NpmRegistry {
25    http: HttpRegistry,
26    base: Url,
27}
28
29/// The part of a packument that lists published versions.
30#[derive(Debug, Facet)]
31struct Packument {
32    versions: Option<BTreeMap<String, Value>>,
33}
34
35impl NpmRegistry {
36    /// Look up packages under the registry base `base`.
37    ///
38    /// A missing trailing slash on the path is added. Without a `token`,
39    /// requests are anonymous.
40    ///
41    /// # Errors
42    ///
43    /// Fails when the token is not a valid header value or the HTTP client
44    /// cannot start.
45    pub fn new(mut base: Url, token: Option<&Token>) -> Result<Self, RegistryError> {
46        if !base.path().ends_with('/') {
47            base.set_path(&format!("{}/", base.path()));
48        }
49        Ok(Self {
50            http: HttpRegistry::new("Bearer", token)?,
51            base,
52        })
53    }
54}
55
56impl PackageRegistry for NpmRegistry {
57    type Error = RegistryError;
58
59    async fn package_exists(&self, package: &str, version: &str) -> Result<bool, Self::Error> {
60        let path = utf8_percent_encode(package, NAME_SEGMENT).to_string();
61        let url = self
62            .base
63            .join(&path)
64            .map_err(|source| RegistryError::InvalidPath { path, source })?;
65        let Some(body) = self.http.get(&url).await? else {
66            return Ok(false);
67        };
68        let packument: Packument = facet_json::from_str(&body).map_err(RegistryError::Body)?;
69        Ok(packument
70            .versions
71            .is_some_and(|versions| versions.contains_key(version)))
72    }
73
74    fn base_url(&self) -> Option<&Url> {
75        Some(&self.base)
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use ephemeris_loopback::{Response, Server};
82    use ephemeris_token::Token;
83
84    use super::NpmRegistry;
85    use crate::PackageRegistry as _;
86    use crate::adapters::registry::RegistryError;
87
88    const PACKUMENT: &str = r#"{"name":"web","dist-tags":{"latest":"1.2.3"},"versions":{"1.2.2":{"name":"web"},"1.2.3":{"name":"web"}}}"#;
89
90    #[tokio::test]
91    async fn a_listed_version_exists_and_the_token_is_a_bearer() {
92        let server = Server::start(vec![Response::new(200, PACKUMENT)]);
93        let registry = NpmRegistry::new(
94            server.url().join("api/packages/acme/npm").unwrap(),
95            Some(&Token::new("secret")),
96        )
97        .unwrap();
98
99        assert!(registry.package_exists("web", "1.2.3").await.unwrap());
100
101        let requests = server.requests();
102        assert_eq!(requests[0].path, "/api/packages/acme/npm/web");
103        assert_eq!(requests[0].header("authorization"), Some("Bearer secret"));
104    }
105
106    #[tokio::test]
107    async fn an_unlisted_version_is_absent() {
108        let server = Server::start(vec![Response::new(200, PACKUMENT)]);
109        let registry = NpmRegistry::new(server.url(), None).unwrap();
110
111        assert!(!registry.package_exists("web", "2.0.0").await.unwrap());
112        assert_eq!(server.requests()[0].header("authorization"), None);
113    }
114
115    #[tokio::test]
116    async fn a_packument_without_versions_has_none() {
117        let server = Server::start(vec![Response::new(200, r#"{"name":"web"}"#)]);
118        let registry = NpmRegistry::new(server.url(), None).unwrap();
119
120        assert!(!registry.package_exists("web", "1.2.3").await.unwrap());
121    }
122
123    #[tokio::test]
124    async fn an_unknown_package_is_absent() {
125        let server = Server::start(vec![Response::new(404, "{}")]);
126        let registry = NpmRegistry::new(server.url(), None).unwrap();
127
128        assert!(!registry.package_exists("web", "1.2.3").await.unwrap());
129    }
130
131    #[tokio::test]
132    async fn server_errors_are_retried() {
133        let server = Server::start(vec![
134            Response::new(503, "busy"),
135            Response::new(200, PACKUMENT),
136        ]);
137        let registry = NpmRegistry::new(server.url(), None).unwrap();
138
139        assert!(registry.package_exists("web", "1.2.3").await.unwrap());
140        assert_eq!(server.requests().len(), 2);
141    }
142
143    #[tokio::test]
144    async fn client_errors_are_not_retried() {
145        let server = Server::start(vec![Response::new(401, "denied")]);
146        let registry = NpmRegistry::new(server.url(), None).unwrap();
147
148        assert!(matches!(
149            registry.package_exists("web", "1.2.3").await.unwrap_err(),
150            RegistryError::Response { status: 401, body } if body == "denied"
151        ));
152        assert_eq!(server.requests().len(), 1);
153    }
154
155    #[tokio::test]
156    async fn a_body_that_is_no_packument_is_an_error() {
157        let server = Server::start(vec![Response::new(200, "not json")]);
158        let registry = NpmRegistry::new(server.url(), None).unwrap();
159
160        assert!(matches!(
161            registry.package_exists("web", "1.2.3").await.unwrap_err(),
162            RegistryError::Body(_)
163        ));
164    }
165
166    #[tokio::test]
167    async fn a_scoped_name_encodes_its_slash() {
168        let server = Server::start(vec![Response::new(404, "{}")]);
169        let registry = NpmRegistry::new(server.url(), None).unwrap();
170
171        registry.package_exists("@acme/web", "1.2.3").await.unwrap();
172
173        assert_eq!(server.requests()[0].path, "/@acme%2Fweb");
174    }
175}