use reqwest::{ClientBuilder, Response};
use serde::Deserialize;
use std::collections::HashMap;
use thiserror::Error;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
const CRATES_IO_URL: &str = "https://crates.io/api/v1/crates";
const UNIQUE_USER_AGENT: &str = "krates/0.3.0";
#[derive(Error, Debug)]
enum KrateError {
#[error("Crate name is not found. Did you mispell the crate name?")]
KrateNotFound,
#[error("User Agent must be a string with at least one character")]
UserAgentNotProvided,
#[error("Server Status Error: {0}")]
OtherKrateError(reqwest::Error),
}
impl Krate {
pub fn get_latest(&self) -> String {
String::from(&self.versions[0].num)
}
pub fn get_features_for_version(&self, version: &str) -> Option<&HashMap<String, Vec<String>>> {
for v in &self.versions {
if v.num == version {
if let Some(features) = &v.features {
return Some(features);
}
}
}
None
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Krate {
pub categories: Option<Vec<KrateCategory>>,
pub versions: Vec<KrateVersion>,
#[serde(rename = "crate")]
pub krate: KrateMetadata,
pub keywords: Option<Vec<Option<KrateKeyword>>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrateVersion {
pub crate_size: Option<i64>,
pub license: Option<String>,
pub num: String,
pub readme_path: String,
pub yanked: bool,
pub features: Option<HashMap<String, Vec<String>>>,
pub id: i64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrateCategory {
pub category: String,
pub crates_cnt: i32,
pub created_at: String,
pub description: String,
pub id: String,
pub slug: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrateMetadata {
pub categories: Vec<String>,
pub created_at: String,
pub description: String,
pub documentation: Option<String>,
pub downloads: i32,
pub exact_match: bool,
pub homepage: Option<String>,
pub id: String,
pub keywords: Vec<String>,
pub max_version: String,
pub max_stable_version: String,
pub name: String,
pub newest_version: String,
pub recent_downloads: i64,
pub repository: Option<String>,
pub updated_at: String,
pub versions: Vec<i32>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KrateKeyword {
pub crates_cnt: i64,
pub created_at: String,
pub id: String,
pub keyword: String,
}
#[derive(Debug)]
pub struct SyncKrateClient {
client: reqwest::blocking::Client,
}
#[derive(Debug)]
pub struct AsyncKrateClient {
client: reqwest::Client,
}
impl SyncKrateClient {
pub fn get(&self, crate_name: &str) -> Result<Krate> {
let url = format!("{CRATES_IO_URL}/{crate_name}");
let res = self.client.get(url).send()?;
match res.error_for_status() {
Ok(res) => {
let krate: Krate = res.json()?;
Ok(krate)
}
Err(e) => Err(handle_error(e).into()),
}
}
}
impl AsyncKrateClient {
pub async fn get_async(&self, crate_name: &str) -> Result<Krate> {
let url = format!("{CRATES_IO_URL}/{crate_name}");
let res: Response = self.client.get(url).send().await?;
match res.error_for_status() {
Ok(res) => {
let krate: Krate = res.json().await?;
Ok(krate)
}
Err(e) => Err(handle_error(e).into()),
}
}
}
pub struct KrateClientBuilder {
user_agent: String,
}
impl KrateClientBuilder {
pub fn new(user_agent: &str) -> KrateClientBuilder {
KrateClientBuilder {
user_agent: user_agent.to_string(),
}
}
pub fn build_sync(&self) -> Result<SyncKrateClient> {
if has_empty_user_agent(&self.user_agent) {
return Err(Box::new(KrateError::UserAgentNotProvided));
}
let operator_user_agent = format!(
"{} - Brought to you by: {UNIQUE_USER_AGENT}",
self.user_agent
);
let client = reqwest::blocking::ClientBuilder::new()
.user_agent(&operator_user_agent)
.build()?;
return Ok(SyncKrateClient { client: client });
}
pub fn build_asnyc(&self) -> Result<AsyncKrateClient> {
if has_empty_user_agent(&self.user_agent) {
if has_empty_user_agent(&self.user_agent) {
return Err(Box::new(KrateError::UserAgentNotProvided));
}
}
let operator_user_agent = format!(
"{} - Brought to you by: {UNIQUE_USER_AGENT}",
self.user_agent
);
let client = reqwest::ClientBuilder::new()
.user_agent(&operator_user_agent)
.build()?;
return Ok(AsyncKrateClient { client: client });
}
}
fn handle_error(e: reqwest::Error) -> KrateError {
if e.status() == Some(reqwest::StatusCode::NOT_FOUND) {
KrateError::KrateNotFound
} else {
KrateError::OtherKrateError(e)
}
}
fn has_empty_user_agent(user_agent: &str) -> bool {
user_agent.trim().len() == 0
}
pub fn get(crate_name: &str, user_agent: &str) -> Result<Krate> {
let url = format!("{CRATES_IO_URL}/{crate_name}");
let client = reqwest::blocking::ClientBuilder::new()
.user_agent(format!(
"{user_agent} - Brought to you by: {UNIQUE_USER_AGENT}",
))
.build()?;
let res = client.get(url).send()?;
match res.error_for_status() {
Ok(res) => {
let krate: Krate = res.json()?;
Ok(krate)
}
Err(e) => Err(handle_error(e).into()),
}
}
pub async fn get_async(crate_name: &str, user_agent: &str) -> Result<Krate> {
let url = format!("{CRATES_IO_URL}/{crate_name}");
let client = ClientBuilder::new()
.user_agent(format!(
"{user_agent} - Brought to you by: {UNIQUE_USER_AGENT}",
))
.build()?;
let res: Response = client.get(url).send().await?;
match res.error_for_status() {
Ok(res) => {
let krate: Krate = res.json().await?;
Ok(krate)
}
Err(e) => Err(handle_error(e).into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn client_builder() -> KrateClientBuilder {
KrateClientBuilder::new("Test Mocks for TheLarkInn/krate")
}
fn get_sync_krate_client() -> SyncKrateClient {
client_builder().build_sync().unwrap()
}
fn get_async_krate_client() -> AsyncKrateClient {
client_builder().build_asnyc().unwrap()
}
#[tokio::test]
async fn test_get_async_crate_basic() {
let krate = get_async_krate_client().get_async("is-wsl").await.unwrap();
assert_eq!(krate.krate.name, "is-wsl");
}
#[tokio::test]
async fn test_get_async_latest_version_from_crate() {
let krate: Krate = get_async_krate_client().get_async("tokio").await.unwrap();
assert_eq!(krate.get_latest(), krate.versions[0].num);
}
#[tokio::test]
async fn test_get_async_informs_operator_of_not_found_error() {
let krate = get_async_krate_client().get_async("tokioz").await;
assert!(krate.is_err());
assert_eq!(
krate.err().unwrap().to_string(),
"Crate name is not found. Did you mispell the crate name?"
);
}
#[tokio::test]
async fn test_get_async_errors_on_empty_user_agent() {
let builder = KrateClientBuilder::new(" ").build_asnyc();
assert_eq!(
builder.err().unwrap().to_string(),
"User Agent must be a string with at least one character"
);
}
#[test]
fn test_get_crate_basic() {
let krate = get_sync_krate_client().get("is-interactive").unwrap();
assert_eq!(krate.krate.name, "is-interactive");
assert_eq!(krate.versions[0].num, "0.1.0");
assert_eq!(
krate.krate.description,
"Checks if stdout or stderr is interactive"
);
}
#[test]
fn test_get_get_latest() {
let krate: Krate = get_sync_krate_client().get("syn").unwrap();
assert_eq!(krate.get_latest(), krate.versions[0].num);
}
#[test]
fn test_get_features_for_version() {
let krate: Krate = get_sync_krate_client().get("tokio").unwrap();
let features = krate.get_features_for_version("1.24.2");
assert_eq!(features.unwrap().len(), 15);
}
#[test]
fn test_get_features_for_wrong_version() {
let krate: Krate = get_sync_krate_client().get("cargo-outdated").unwrap();
let features = krate.get_features_for_version("9999.0.00");
assert!(features.is_none());
}
#[test]
fn test_edge_case_packages_without_data() {
let krate = get_sync_krate_client().get("rustc-workspace-hack");
assert!(krate.is_ok())
}
}