ephemeris_release/adapters/npm/
registry.rs1use 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
14const NAME_SEGMENT: &AsciiSet = &CONTROLS.add(b'/').add(b'?').add(b'#').add(b'%').add(b' ');
16
17#[derive(Debug)]
24pub struct NpmRegistry {
25 http: HttpRegistry,
26 base: Url,
27}
28
29#[derive(Debug, Facet)]
31struct Packument {
32 versions: Option<BTreeMap<String, Value>>,
33}
34
35impl NpmRegistry {
36 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}