1use crate::{
6 auth::get_auth_headers,
7 config::{Dependency, HttpDependency},
8 errors::RegistryError,
9};
10use chrono::{DateTime, Utc};
11use log::{debug, warn};
12use reqwest::{Client, Url};
13use semver::{Version, VersionReq};
14use serde::Deserialize;
15use std::env;
16
17pub type Result<T> = std::result::Result<T, RegistryError>;
18
19#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize))]
22pub struct Revision {
23 pub id: uuid::Uuid,
25
26 pub version: String,
28
29 pub internal_name: String,
31
32 pub url: String,
34
35 pub project_id: uuid::Uuid,
37
38 pub deleted: bool,
40
41 pub created_at: Option<DateTime<Utc>>,
43
44 pub private: Option<bool>,
46}
47
48#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize))]
51pub struct Project {
52 pub id: uuid::Uuid,
54
55 pub name: String,
57
58 pub description: String,
60
61 pub github_url: String,
63
64 pub created_by: uuid::Uuid,
66
67 pub deleted: Option<bool>,
69
70 pub private: Option<bool>,
72
73 pub downloads: Option<i64>,
75 pub image: Option<String>,
76 pub long_description: Option<String>,
77 pub created_at: Option<DateTime<Utc>>,
78 pub updated_at: Option<DateTime<Utc>>,
79 pub organization_id: Option<uuid::Uuid>,
80 pub latest_version: Option<String>,
81 pub deprecated: Option<bool>,
82 pub organization_name: Option<String>,
83 pub organization_verified: Option<bool>,
84}
85
86#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
88#[cfg_attr(feature = "serde", derive(serde::Serialize))]
89pub struct RevisionResponse {
90 data: Vec<Revision>,
92
93 status: String,
95}
96
97#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize))]
100pub struct ProjectResponse {
101 data: Vec<Project>,
103
104 status: String,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Hash)]
110#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
111pub struct DownloadUrl {
112 pub url: String,
114
115 pub private: bool,
117}
118
119pub fn api_url(version: &str, path: &str, params: &[(&str, &str)]) -> Url {
140 let url = env::var("SOLDEER_API_URL").unwrap_or("https://api.soldeer.xyz".to_string());
141 let mut url = Url::parse(&url).expect("SOLDEER_API_URL is invalid");
142 url.set_path(&format!("api/{version}/{path}"));
143 if params.is_empty() {
144 return url;
145 }
146 url.query_pairs_mut().extend_pairs(params.iter());
147 url
148}
149
150pub async fn get_dependency_url_remote(
152 dependency: &Dependency,
153 version: &str,
154) -> Result<DownloadUrl> {
155 debug!(dep:% = dependency; "retrieving URL for dependency");
156 let url = api_url(
157 "v1",
158 "revision-cli",
159 &[("project_name", dependency.name()), ("revision", version)],
160 );
161
162 let res = Client::new().get(url).headers(get_auth_headers()?).send().await?;
163 let res = res.error_for_status()?;
164 let revision: RevisionResponse = res.json().await?;
165 let Some(r) = revision.data.first() else {
166 return Err(RegistryError::URLNotFound(dependency.to_string()));
167 };
168 debug!(dep:% = dependency, url = r.url; "URL for dependency was found");
169 Ok(DownloadUrl { url: r.url.clone(), private: r.private.unwrap_or_default() })
170}
171
172pub async fn get_project_id(dependency_name: &str) -> Result<String> {
174 debug!(name = dependency_name; "retrieving project ID");
175 let url = api_url("v2", "project", &[("project_name", dependency_name)]);
176 let res = Client::new().get(url).headers(get_auth_headers()?).send().await?;
177 let res = res.error_for_status()?;
178 let project: ProjectResponse = res.json().await?;
179 let Some(p) = project.data.first() else {
180 return Err(RegistryError::ProjectNotFound(dependency_name.to_string()));
181 };
182 debug!(name = dependency_name, id:% = p.id; "project ID was found");
183 Ok(p.id.to_string())
184}
185
186pub async fn get_latest_version(dependency_name: &str) -> Result<Dependency> {
188 debug!(dep = dependency_name; "retrieving latest version for dependency");
189 let url = api_url(
190 "v1",
191 "revision",
192 &[("project_name", dependency_name), ("offset", "0"), ("limit", "1")],
193 );
194 let res = Client::new().get(url).headers(get_auth_headers()?).send().await?;
195 let res = res.error_for_status()?;
196 let revision: RevisionResponse = res.json().await?;
197 let Some(data) = revision.data.first() else {
198 return Err(RegistryError::URLNotFound(dependency_name.to_string()));
199 };
200 debug!(dep = dependency_name, version = data.version; "latest version found");
201 Ok(HttpDependency {
202 name: dependency_name.to_string(),
203 version_req: data.clone().version,
204 url: None,
205 project_root: None,
206 }
207 .into())
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Hash)]
216pub enum Versions {
217 Semver(Vec<Version>),
219
220 NonSemver(Vec<String>),
222}
223
224pub async fn get_all_versions_descending(dependency_name: &str) -> Result<Versions> {
230 debug!(dep = dependency_name; "retrieving all dependency versions");
233 let url = api_url(
234 "v1",
235 "revision",
236 &[("project_name", dependency_name), ("offset", "0"), ("limit", "10000")],
237 );
238 let res = Client::new().get(url).headers(get_auth_headers()?).send().await?;
239 let res = res.error_for_status()?;
240 let revision: RevisionResponse = res.json().await?;
241 if revision.data.is_empty() {
242 return Err(RegistryError::NoVersion(dependency_name.to_string()));
243 }
244
245 match revision
246 .data
247 .iter()
248 .map(|r| Version::parse(&r.version))
249 .collect::<std::result::Result<Vec<Version>, _>>()
250 {
251 Ok(mut versions) => {
252 debug!(dep = dependency_name; "all versions are semver compliant, sorting by descending version");
253 versions.sort_unstable_by(|a, b| b.cmp(a)); Ok(Versions::Semver(versions))
255 }
256 Err(_) => {
257 debug!(dep = dependency_name; "not all versions are semver compliant, using API ordering");
258 Ok(Versions::NonSemver(revision.data.iter().map(|r| r.version.to_string()).collect()))
259 }
260 }
261}
262
263pub async fn get_latest_supported_version(dependency: &Dependency) -> Result<String> {
268 debug!(dep:% = dependency, version_req = dependency.version_req(); "retrieving latest version according to version requirement");
269 match get_all_versions_descending(dependency.name()).await? {
270 Versions::Semver(all_versions) => {
271 match parse_version_req(dependency.version_req()) {
272 Some(req) => {
273 let new_version = all_versions
274 .iter()
275 .find(|version| req.matches(version))
276 .ok_or(RegistryError::NoMatchingVersion {
277 dependency: dependency.name().to_string(),
278 version_req: dependency.version_req().to_string(),
279 })?;
280 debug!(dep:% = dependency, version:% = new_version; "acceptable version found");
281 Ok(new_version.to_string())
282 }
283 None => {
284 warn!(dep:% = dependency, version_req = dependency.version_req(); "could not parse version req according to semver, using latest version");
285 Ok(all_versions
287 .into_iter()
288 .next()
289 .map(|v| v.to_string())
290 .expect("there should be at least 1 version"))
291 }
292 }
293 }
294 Versions::NonSemver(all_versions) => {
295 debug!(dep:% = dependency; "versions are not all semver compliant, trying to find exact match");
298 all_versions.into_iter().find(|v| v == dependency.version_req()).ok_or_else(|| {
299 RegistryError::NoMatchingVersion {
300 dependency: dependency.name().to_string(),
301 version_req: dependency.version_req().to_string(),
302 }
303 })
304 }
305 }
306}
307
308pub fn parse_version_req(version_req: &str) -> Option<VersionReq> {
314 let Ok(mut req) = version_req.parse::<VersionReq>() else {
315 debug!(version_req; "version requirement cannot be parsed by semver");
316 return None;
317 };
318 if req.comparators.is_empty() {
319 debug!(version_req; "comparators list is empty (wildcard req), no further action needed");
320 return Some(req); }
322 let orig_items: Vec<_> = version_req.split(',').collect();
323 if orig_items.len() == req.comparators.len() {
327 for (comparator, orig) in req.comparators.iter_mut().zip(orig_items) {
328 if comparator.op == semver::Op::Caret && !orig.trim_start_matches(' ').starts_with('^')
329 {
330 debug!(comparator:% = comparator; "adding exact operator for comparator");
331 comparator.op = semver::Op::Exact;
332 }
333 }
334 }
335 Some(req)
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use mockito::{Matcher, Server};
342 use temp_env::async_with_vars;
343
344 #[tokio::test]
345 async fn test_get_dependency_url() {
346 let mut server = Server::new_async().await;
347 let data = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3391,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"}],"status":"success"}"#;
348 server
349 .mock("GET", "/api/v1/revision-cli")
350 .match_query(Matcher::Any)
351 .with_header("content-type", "application/json")
352 .with_body(data)
353 .create_async()
354 .await;
355
356 let dependency =
357 HttpDependency::builder().name("forge-std").version_req("^1.9.0").build().into();
358 let res = async_with_vars(
359 [("SOLDEER_API_URL", Some(server.url()))],
360 get_dependency_url_remote(&dependency, "1.9.2"),
361 )
362 .await;
363 assert!(res.is_ok(), "{res:?}");
364 assert_eq!(
365 res.unwrap().url,
366 "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip"
367 );
368 }
369
370 #[tokio::test]
371 async fn test_get_dependency_url_nomatch() {
372 let mut server = Server::new_async().await;
373 let data = r#"{"data":[],"status":"success"}"#;
374 server
375 .mock("GET", "/api/v1/revision-cli")
376 .match_query(Matcher::Any)
377 .with_header("content-type", "application/json")
378 .with_body(data)
379 .create_async()
380 .await;
381
382 let dependency =
383 HttpDependency::builder().name("forge-std").version_req("^1.9.0").build().into();
384 let res = async_with_vars(
385 [("SOLDEER_API_URL", Some(server.url()))],
386 get_dependency_url_remote(&dependency, "1.9.2"),
387 )
388 .await;
389 assert!(matches!(res, Err(RegistryError::URLNotFound(_))));
390 }
391
392 #[tokio::test]
393 async fn test_get_project_id() {
394 let mut server = Server::new_async().await;
395 let data = r#"{"data":[{"created_at":"2024-02-27T19:19:23.938837Z","created_by":"96228bb5-f777-4c19-ba72-363d14b8beed","deleted":false,"deprecated":false,"description":"Forge Standard Library is a collection of helpful contracts and libraries for use with Forge and Foundry.","downloads":648041,"github_url":"https://github.com/foundry-rs/forge-std","id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","image":"https://soldeer-resources.s3.amazonaws.com/default_icon.png","latest_version":"1.10.0","long_description":"Description","name":"forge-std","organization_id":"ff9c0d8e-9275-4f6f-a1b7-2e822450a7ba","organization_name":"Soldeer","organization_verified":true,"updated_at":"2024-02-27T19:19:23.938837Z"}],"status":"success"}"#;
396 server
397 .mock("GET", "/api/v2/project")
398 .match_query(Matcher::Any)
399 .with_header("content-type", "application/json")
400 .with_body(data)
401 .create_async()
402 .await;
403 let res =
404 async_with_vars([("SOLDEER_API_URL", Some(server.url()))], get_project_id("forge-std"))
405 .await;
406 assert!(res.is_ok(), "{res:?}");
407 assert_eq!(res.unwrap(), "37adefe5-9bc6-4777-aaf2-e56277d1f30b");
408 }
409
410 #[tokio::test]
411 async fn test_get_project_id_nomatch() {
412 let mut server = Server::new_async().await;
413 let data = r#"{"data":[],"status":"success"}"#;
414 server
415 .mock("GET", "/api/v2/project")
416 .match_query(Matcher::Any)
417 .with_header("content-type", "application/json")
418 .with_body(data)
419 .create_async()
420 .await;
421
422 let res =
423 async_with_vars([("SOLDEER_API_URL", Some(server.url()))], get_project_id("forge-std"))
424 .await;
425 assert!(matches!(res, Err(RegistryError::ProjectNotFound(_))));
426 }
427
428 #[tokio::test]
429 async fn test_get_latest_forge_std() {
430 let mut server = Server::new_async().await;
431 let data = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3391,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"}],"status":"success"}"#;
432 server
433 .mock("GET", "/api/v1/revision")
434 .match_query(Matcher::Any)
435 .with_header("content-type", "application/json")
436 .with_body(data)
437 .create_async()
438 .await;
439
440 let dependency =
441 HttpDependency::builder().name("forge-std").version_req("1.9.2").build().into();
442 let res = async_with_vars(
443 [("SOLDEER_API_URL", Some(server.url()))],
444 get_latest_version("forge-std"),
445 )
446 .await;
447 assert!(res.is_ok(), "{res:?}");
448 assert_eq!(res.unwrap(), dependency);
449 }
450
451 #[tokio::test]
452 async fn test_get_all_versions_descending() {
453 let mut server = Server::new_async().await;
454 let data = r#"{"data":[{"created_at":"2024-07-03T14:44:58.148723Z","deleted":false,"downloads":21,"id":"b463683a-c4b4-40bf-b707-1c4eb343c4d2","internal_name":"forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","version":"1.9.0"},{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3389,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"},{"created_at":"2024-07-03T14:44:59.729623Z","deleted":false,"downloads":5290,"id":"fa5160fc-ba7b-40fd-8e99-8becd6dadbe4","internal_name":"forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","version":"1.9.1"}],"status":"success"}"#;
456 server
457 .mock("GET", "/api/v1/revision")
458 .match_query(Matcher::Any)
459 .with_header("content-type", "application/json")
460 .with_body(data)
461 .create_async()
462 .await;
463
464 let res = async_with_vars(
465 [("SOLDEER_API_URL", Some(server.url()))],
466 get_all_versions_descending("forge-std"),
467 )
468 .await;
469 assert!(res.is_ok(), "{res:?}");
470 assert_eq!(
471 res.unwrap(),
472 Versions::Semver(vec![
473 "1.9.2".parse().unwrap(),
474 "1.9.1".parse().unwrap(),
475 "1.9.0".parse().unwrap()
476 ])
477 );
478 }
479
480 #[tokio::test]
481 async fn test_get_latest_supported_version_semver() {
482 let mut server = Server::new_async().await;
483 let data = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3389,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"},{"created_at":"2024-07-03T14:44:59.729623Z","deleted":false,"downloads":5290,"id":"fa5160fc-ba7b-40fd-8e99-8becd6dadbe4","internal_name":"forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","version":"1.9.1"},{"created_at":"2024-07-03T14:44:58.148723Z","deleted":false,"downloads":21,"id":"b463683a-c4b4-40bf-b707-1c4eb343c4d2","internal_name":"forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","version":"1.9.0"}],"status":"success"}"#;
484 server
485 .mock("GET", "/api/v1/revision")
486 .match_query(Matcher::Any)
487 .with_header("content-type", "application/json")
488 .with_body(data)
489 .create_async()
490 .await;
491
492 let dependency: Dependency =
493 HttpDependency::builder().name("forge-std").version_req("^1.9.0").build().into();
494 let res = async_with_vars(
495 [("SOLDEER_API_URL", Some(server.url()))],
496 get_latest_supported_version(&dependency),
497 )
498 .await;
499 assert!(res.is_ok(), "{res:?}");
500 assert_eq!(res.unwrap(), "1.9.2");
501 }
502
503 #[tokio::test]
504 async fn test_get_latest_supported_version_no_semver() {
505 let mut server = Server::new_async().await;
506 let data = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3389,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"2024-08"},{"created_at":"2024-07-03T14:44:59.729623Z","deleted":false,"downloads":5290,"id":"fa5160fc-ba7b-40fd-8e99-8becd6dadbe4","internal_name":"forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","version":"2024-07"},{"created_at":"2024-07-03T14:44:58.148723Z","deleted":false,"downloads":21,"id":"b463683a-c4b4-40bf-b707-1c4eb343c4d2","internal_name":"forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","version":"2024-06"}],"status":"success"}"#;
507 server
508 .mock("GET", "/api/v1/revision")
509 .match_query(Matcher::Any)
510 .with_header("content-type", "application/json")
511 .with_body(data)
512 .create_async()
513 .await;
514
515 let dependency: Dependency =
516 HttpDependency::builder().name("forge-std").version_req("2024-06").build().into();
517 let res = async_with_vars(
518 [("SOLDEER_API_URL", Some(server.url()))],
519 get_latest_supported_version(&dependency),
520 )
521 .await;
522 assert!(res.is_ok(), "{res:?}");
523 assert_eq!(res.unwrap(), "2024-06"); let dependency: Dependency =
526 HttpDependency::builder().name("forge-std").version_req("non-existant").build().into();
527 let res = async_with_vars(
528 [("SOLDEER_API_URL", Some(server.url()))],
529 get_latest_supported_version(&dependency),
530 )
531 .await;
532 assert!(matches!(res, Err(RegistryError::NoMatchingVersion { .. })));
533 }
534
535 #[test]
536 fn test_parse_version_req() {
537 assert_eq!(parse_version_req("1.9.0"), Some(VersionReq::parse("=1.9.0").unwrap()));
538 assert_eq!(parse_version_req("=1.9.0"), Some(VersionReq::parse("=1.9.0").unwrap()));
539 assert_eq!(parse_version_req("^1.9.0"), Some(VersionReq::parse("^1.9.0").unwrap()));
540 assert_eq!(
541 parse_version_req("^1.9.0,^1.10.0"),
542 Some(VersionReq::parse("^1.9.0, ^1.10.0").unwrap())
543 );
544 assert_eq!(
545 parse_version_req("1.9.0,1.10.0"),
546 Some(VersionReq::parse("=1.9.0,=1.10.0").unwrap())
547 );
548 assert_eq!(parse_version_req(">=1.9.0"), Some(VersionReq::parse(">=1.9.0").unwrap()));
549 assert_eq!(parse_version_req(""), None);
550 assert_eq!(parse_version_req("foobar"), None);
551 assert_eq!(parse_version_req("*"), Some(VersionReq::STAR));
552 }
553}