Skip to content

Commit

Permalink
feat: provide get_latest_version function
Browse files Browse the repository at this point in the history
Gets the latest version of a given binary released in the `safe_network` repository. Since the
`safe_network` is a workspace repo where many binaries are releases, we can't use the more straight
forward 'get latest release' API, since the version number on that relates to one of many binaries.

As mentioned in the doc comments, we are trying to limit the search to releases that occur within
the last 14 days. The `safe_network` repo currently has over 840 releases, so searching through the
whole list means fetching many pages from Github, which can actually be quite slow.

I made the decision to integration test rather than unit test, because the latter would involve
setting up tedious mock responses, and the code really shouldn't change very much over time. We can
use `--test-threads 1` in CI to try and mitigate rate-limiting errors.
  • Loading branch information
jacderida authored and joshuef committed Oct 19, 2023
1 parent 048e059 commit d5404c5
Show file tree
Hide file tree
Showing 5 changed files with 223 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@ Cargo.lock

# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb


# Added by cargo

/target
/Cargo.lock
13 changes: 13 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[package]
name = "sn-releases"
version = "0.1.0"
edition = "2021"

[dependencies]
chrono = "0.4.26"
lazy_static = "1.4.0"
regex = "1.10.2"
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
serde_json = "1.0"
thiserror = "1.0.49"
tokio = { version = "1.26", features = ["full"] }
18 changes: 18 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use thiserror::Error;

pub type Result<T, E = Error> = std::result::Result<T, E>;

#[derive(Debug, Error)]
#[allow(missing_docs)]
pub enum Error {
#[error(transparent)]
DateTimeParseError(#[from] chrono::ParseError),
#[error("Could not convert API response header links to string")]
HeaderLinksToStrError,
#[error("Latest release not found for {0}")]
LatestReleaseNotFound(String),
#[error(transparent)]
ReqwestError(#[from] reqwest::Error),
#[error("Could not parse version from tag name")]
TagNameVersionParsingFailed,
}
157 changes: 157 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
pub mod error;

use crate::error::{Error, Result};
use chrono::{DateTime, Duration, Utc};
use lazy_static::lazy_static;
use reqwest::{header::HeaderMap, Client, Response};
use serde_json::Value;
use std::collections::HashMap;
use std::fmt;

const GITHUB_API_URL: &str = "https://api.github.com";
const GITHUB_ORG_NAME: &str = "maidsafe";
const GITHUB_REPO_NAME: &str = "safe_network";

#[derive(Clone, Eq, Hash, PartialEq)]
pub enum ReleaseType {
Safe,
Safenode,
Testnet,
}

impl fmt::Display for ReleaseType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
match self {
ReleaseType::Safe => "safe",
ReleaseType::Safenode => "safenode",
ReleaseType::Testnet => "testnet",
}
)
}
}

lazy_static! {
static ref RELEASE_TYPE_CRATE_NAME_MAP: HashMap<ReleaseType, &'static str> = {
let mut m = HashMap::new();
m.insert(ReleaseType::Safe, "sn_cli");
m.insert(ReleaseType::Safenode, "sn_node");
m.insert(ReleaseType::Testnet, "sn_testnet");
m
};
}

/// Gets the latest version for a specified binary in the `safe_network` repository.
///
/// Each release in the repository is checked, starting from the most recent. The `safe_network`
/// repository is a workspace to which many binaries are released, so it's not possible to use the
/// more straight forward Github API which simply returns the latest release, since that's going to
/// be the version number for one of many binaries.
///
/// During the search, if a release is found that was created more than 14 days ago, the function
/// will stop searching through older releases, which will avoid fetching further pages from the
/// Github API.
///
/// # Arguments
///
/// * `release_type` - A reference to a `ReleaseType` enum specifying the type of release to look for.
///
/// # Returns
///
/// Returns a `Result` containing a `String` with the latest version number in the semantic format.
/// Otherwise, returns an `Error`.
///
/// # Errors
///
/// This function will return an error if:
/// - The HTTP request to GitHub API fails
/// - The received JSON data from the API is not as expected
/// - No releases are found that match the specified `ReleaseType`
pub async fn get_latest_version(release_type: &ReleaseType) -> Result<String> {
let mut page = 1;
let per_page = 100;
let mut latest_release: Option<(String, DateTime<Utc>)> = None;
let target_tag_name = *RELEASE_TYPE_CRATE_NAME_MAP.get(release_type).unwrap();
let now = Utc::now();

loop {
let response = get_releases_page(page, per_page).await?;
let headers = response.headers().clone();
let releases = response.json::<Value>().await?;

let mut continue_search = true;
if let Value::Array(releases) = releases {
for release in releases {
if let Value::Object(release) = release {
if let (Some(Value::String(tag_name)), Some(Value::String(created_at))) =
(release.get("tag_name"), release.get("created_at"))
{
let created_at = created_at.parse::<DateTime<Utc>>()?;
if tag_name.starts_with(target_tag_name) {
match latest_release {
Some((_, date)) if created_at > date => {
latest_release = Some((tag_name.clone(), created_at));
}
None => {
latest_release = Some((tag_name.clone(), created_at));
}
_ => {}
}
}

if now.signed_duration_since(created_at) > Duration::days(14) {
continue_search = false;
break;
}
}
}
}
}

if continue_search && has_next_page(&headers).await? {
page += 1;
} else {
break;
}
}

let tag_name = latest_release
.ok_or_else(|| Error::LatestReleaseNotFound(release_type.to_string()))?
.0;
let version = get_version_from_tag_name(&tag_name)?;
Ok(version)
}

async fn get_releases_page(page: u32, per_page: u32) -> Result<Response> {
let client = Client::new();
let response = client
.get(format!(
"{}/repos/{}/{}/releases?page={}&per_page={}",
GITHUB_API_URL, GITHUB_ORG_NAME, GITHUB_REPO_NAME, page, per_page
))
.header("User-Agent", "request")
.send()
.await?;
Ok(response)
}

async fn has_next_page(headers: &HeaderMap) -> Result<bool> {
if let Some(links) = headers.get("link") {
let links = links.to_str().map_err(|_| Error::HeaderLinksToStrError)?;
Ok(links.split(',').any(|link| link.contains("rel=\"next\"")))
} else {
Ok(false)
}
}

fn get_version_from_tag_name(tag_name: &str) -> Result<String> {
let mut parts = tag_name.split('-');
parts.next();
let version = parts
.next()
.ok_or_else(|| Error::TagNameVersionParsingFailed)?
.to_string();
Ok(version.trim_start_matches('v').to_string())
}
29 changes: 29 additions & 0 deletions tests/test_get_latest_version.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use regex::Regex;
use sn_releases::{get_latest_version, ReleaseType};
use tokio;

fn valid_semver_format(version: &str) -> bool {
let re = Regex::new(r"^\d+\.\d+\.\d+$").unwrap();
re.is_match(version)
}

#[tokio::test]
async fn test_get_latest_version_safe() {
let release_type = ReleaseType::Safe;
let version = get_latest_version(&release_type).await.unwrap();
assert!(valid_semver_format(&version));
}

#[tokio::test]
async fn test_get_latest_version_safenode() {
let release_type = ReleaseType::Safenode;
let version = get_latest_version(&release_type).await.unwrap();
assert!(valid_semver_format(&version));
}

#[tokio::test]
async fn test_get_latest_version_testnet() {
let release_type = ReleaseType::Testnet;
let version = get_latest_version(&release_type).await.unwrap();
assert!(valid_semver_format(&version));
}

0 comments on commit d5404c5

Please sign in to comment.