use itertools::Itertools;
use std::fmt::{Display, Formatter, Write};
type NoSearchType = ();
#[derive(Clone, Debug)]
pub enum HashingAlgo {
Sha1,
Sha512,
}
impl Display for HashingAlgo {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Sha1 => write!(f, "sha1"),
Self::Sha512 => write!(f, "sha512"),
}
}
}
#[derive(Debug, Copy, Clone)]
pub enum Requirement {
Optional,
Required,
Unsupported,
}
impl Display for Requirement {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Optional => "optional",
Self::Required => "required",
Self::Unsupported => "unsupported",
};
write!(f, "{s}")
}
}
#[derive(Debug, Clone)]
pub enum SearchType {
Search {
query: String,
limit: Option<u32>,
offset: Option<u32>,
},
Project { id: String },
MultiProject { ids: Vec<&'static str> },
Dependencies { id: String },
ProjectVersion {
id: String,
loaders: Option<Vec<String>>,
},
Version { id: String },
ProjectVersions { ids: Vec<String> },
VersionFile { hash: String, algo: HashingAlgo },
VersionFileUpdate { hash: String, algo: HashingAlgo },
VersionFiles,
VersionFilesUpdate,
Categories,
Loaders,
GameVersions,
ProjectTypes,
}
impl Display for SearchType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
SearchType::Project { id } => write!(f, "project/{id}?"),
SearchType::MultiProject { ids } => {
let ids = ids
.iter()
.map(|id| format!("\"{id}\""))
.collect::<Vec<String>>()
.join(",");
write!(f, "projects?ids=[{ids}]")
}
SearchType::Search { .. } => write!(f, "search"),
SearchType::VersionFile { hash, .. } => write!(f, "version_file/{hash}"),
SearchType::VersionFiles => write!(f, "version_files"),
SearchType::Dependencies { id } => write!(f, "project/{id}/dependencies"),
SearchType::ProjectVersion { id, .. } => write!(f, "project/{id}/version"),
SearchType::ProjectVersions { ids } => {
let ids = ids
.iter()
.map(|id| format!("\"{id}\""))
.collect::<Vec<String>>()
.join(",");
write!(f, "versions?ids=[{ids}]")
}
SearchType::Version { id } => write!(f, "version/{id}"),
SearchType::Categories => write!(f, "tag/category"),
SearchType::Loaders => write!(f, "tag/loader"),
SearchType::VersionFileUpdate { hash, .. } => write!(f, "version_file/{hash}/update"),
SearchType::VersionFilesUpdate => write!(f, "/version_files/update"),
SearchType::GameVersions => write!(f, "/tag/game_version"),
SearchType::ProjectTypes => write!(f, "/tag/project_type"),
}
}
}
#[derive(Default)]
pub struct QueryBuilder {
query: Option<String>,
limit: Option<u32>,
offset: Option<u32>,
}
impl QueryBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn query(mut self, query: impl Into<String>) -> Self {
self.query = Some(query.into());
self
}
pub fn limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
pub fn offset(mut self, offset: u32) -> Self {
self.offset = Some(offset);
self
}
pub fn build(self) -> SearchType {
SearchType::Search {
query: self.query.unwrap_or_default(), limit: self.limit,
offset: self.offset,
}
}
}
#[derive(Default)]
pub struct SearchBuilder<T = NoSearchType> {
search_type: T,
facets: Option<FacetsConjunction>,
game_versions: Vec<String>,
}
impl SearchBuilder<NoSearchType> {
#[must_use]
pub fn new() -> SearchBuilder<NoSearchType> {
SearchBuilder::default()
}
}
impl<T> SearchBuilder<T> {
#[must_use]
pub fn facets(mut self, facets: impl Into<FacetsConjunction>) -> Self {
self.facets = Some(facets.into());
self
}
#[must_use]
pub fn game_versions<E>(mut self, versions: impl IntoIterator<Item = E>) -> Self
where
E: ToString,
{
self.game_versions = versions.into_iter().map(|x| x.to_string()).collect();
self
}
#[must_use]
pub fn add_game_version(mut self, version: impl ToString) -> Self {
self.game_versions.push(version.to_string());
self
}
pub fn search_type(self, search_type: SearchType) -> SearchBuilder<SearchType> {
SearchBuilder {
search_type,
facets: self.facets,
game_versions: self.game_versions,
}
}
}
impl SearchBuilder<SearchType> {
#[must_use]
pub fn build_url(self) -> String {
let mut url: String = "https://api.modrinth.com/v2/".to_string();
write!(url, "{}", self.search_type).unwrap();
let mut is_first = true;
let add_param = |url: &mut String, is_first: &mut bool| {
if *is_first {
url.push('?');
*is_first = false;
} else {
url.push('&');
}
};
match &self.search_type {
SearchType::Categories
| SearchType::Loaders
| SearchType::GameVersions
| SearchType::ProjectTypes => {}
SearchType::Search {
query,
limit,
offset,
} => {
if !query.is_empty() {
add_param(&mut url, &mut is_first);
write!(url, "query=\"{query}\"").unwrap();
}
if let Some(limit) = limit {
add_param(&mut url, &mut is_first);
write!(url, "limit={limit}").unwrap();
}
if let Some(offset) = offset {
add_param(&mut url, &mut is_first);
write!(url, "offset={offset}").unwrap();
}
if self.facets.as_ref().is_some_and(|f| !f.is_empty()) {
add_param(&mut url, &mut is_first);
self.add_facets(&mut url);
}
}
SearchType::ProjectVersion { loaders, .. } => {
let versions = self
.game_versions
.iter()
.map(|v| format!("\"{v}\""))
.join(",");
add_param(&mut url, &mut is_first);
write!(url, "game_versions=[{versions}]").unwrap();
if let Some(loaders) = loaders {
let loaders = loaders.iter().map(|v| format!("\"{v}\"")).join(",");
add_param(&mut url, &mut is_first);
write!(url, "loaders=[{loaders}]").unwrap();
}
}
SearchType::VersionFile { algo, .. } | SearchType::VersionFileUpdate { algo, .. } => {
add_param(&mut url, &mut is_first);
write!(url, "algorithm={algo}").unwrap();
}
_ => {}
}
url
}
fn add_facets(&self, url: &mut String) {
if let Some(ref facets) = self.facets {
let facets_str = facets
.iter()
.map(|conjunction| format!("[{}]", conjunction.facets.iter().join(",")))
.join(",");
write!(url, "facets=[{facets_str}]").unwrap();
}
}
}
#[derive(Debug, Clone, Default)]
pub struct FacetsConjunction {
disjunctions: Vec<FacetsDisjunction>,
}
impl FacetsConjunction {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn new_with(dist: FacetsDisjunction) -> Self {
Self::default().and(dist)
}
#[must_use]
pub fn and(mut self, disj: FacetsDisjunction) -> Self {
self.disjunctions.push(disj);
self
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.disjunctions.is_empty()
}
pub fn iter(&self) -> std::slice::Iter<'_, FacetsDisjunction> {
self.disjunctions.as_slice().iter()
}
}
impl<'a> IntoIterator for &'a FacetsConjunction {
type Item = &'a FacetsDisjunction;
type IntoIter = std::slice::Iter<'a, FacetsDisjunction>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
impl IntoIterator for FacetsConjunction {
type Item = FacetsDisjunction;
type IntoIter = std::vec::IntoIter<FacetsDisjunction>;
fn into_iter(self) -> Self::IntoIter {
self.disjunctions.into_iter()
}
}
impl std::convert::From<Vec<FacetsDisjunction>> for FacetsConjunction {
fn from(value: Vec<FacetsDisjunction>) -> Self {
Self {
disjunctions: value,
}
}
}
impl std::iter::FromIterator<FacetsDisjunction> for FacetsConjunction {
fn from_iter<T: IntoIterator<Item = FacetsDisjunction>>(iter: T) -> Self {
Self {
disjunctions: iter.into_iter().collect(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct FacetsDisjunction {
facets: Vec<Facets>,
}
impl FacetsDisjunction {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn new_with(facet: Facets) -> Self {
Self::default().or(facet)
}
pub fn push(&mut self, facet: Facets) {
self.facets.push(facet);
}
#[must_use]
pub fn or(mut self, facet: Facets) -> Self {
self.push(facet);
self
}
pub fn iter(&self) -> std::slice::Iter<'_, Facets> {
self.facets.as_slice().iter()
}
}
impl Display for FacetsDisjunction {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let s = self.facets.iter().map(|f| f.to_string()).join("v");
write!(f, "{s}")
}
}
impl<'a> IntoIterator for &'a FacetsDisjunction {
type Item = &'a Facets;
type IntoIter = std::slice::Iter<'a, Facets>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
impl IntoIterator for FacetsDisjunction {
type Item = Facets;
type IntoIter = std::vec::IntoIter<Facets>;
fn into_iter(self) -> Self::IntoIter {
self.facets.into_iter()
}
}
impl std::convert::From<Vec<Facets>> for FacetsDisjunction {
fn from(value: Vec<Facets>) -> Self {
Self { facets: value }
}
}
impl std::iter::FromIterator<Facets> for FacetsDisjunction {
fn from_iter<T: IntoIterator<Item = Facets>>(iter: T) -> Self {
Self {
facets: iter.into_iter().collect(),
}
}
}
#[derive(Debug, Clone)]
pub enum Facets {
ProjectType(String),
Categories(String),
Version(String),
ClientSide(Requirement),
ServerSide(Requirement),
OpenSource,
}
impl Display for Facets {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let s = match self {
Facets::ProjectType(t) => format!("\"project_type:{t}\""),
Facets::Categories(c) => format!("\"categories:{c}\""),
Facets::Version(v) => format!("\"versions:{v}\""),
Facets::ClientSide(r) => format!("\"client_side:{r}\""),
Facets::ServerSide(r) => format!("\"server_side:{r}\""),
Facets::OpenSource => todo!(),
};
f.write_str(s.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
pub fn search_builder() {
let search_type = QueryBuilder::new().limit(5).offset(10).build();
let url = SearchBuilder::new().search_type(search_type).build_url();
assert_eq!("https://api.modrinth.com/v2/search?limit=5&offset=10", url)
}
#[test]
pub fn search_builder_facets() {
let versions_facets = FacetsDisjunction::new_with(Facets::Version("1.21".to_string()))
.or(Facets::Version("1.20".to_string()));
let url = SearchBuilder::new()
.facets(vec![versions_facets])
.search_type(QueryBuilder::new().offset(10).limit(5).build())
.build_url();
assert_eq!(
"https://api.modrinth.com/v2/search?limit=5&offset=10&facets=[\
[\"versions:1.21\",\"versions:1.20\"]\
]",
url
);
}
#[test]
pub fn search_builder_facets_disjunction() {
let mut versions_facets = FacetsDisjunction::new_with(Facets::Version("1.21".to_string()));
versions_facets.push(Facets::Version("1.20".to_string()));
let type_facets = FacetsDisjunction::new_with(Facets::ProjectType("modpack".to_string()));
let url = SearchBuilder::new()
.facets(vec![versions_facets, type_facets])
.search_type(QueryBuilder::new().offset(10).limit(5).build())
.build_url();
assert_eq!(
"https://api.modrinth.com/v2/search?limit=5&offset=10&facets=[\
[\"versions:1.21\",\"versions:1.20\"],\
[\"project_type:modpack\"]\
]",
url
);
}
#[test]
pub fn search_builder_facets_disjunction_builder() {
let facets = FacetsConjunction::new()
.and(
FacetsDisjunction::new_with(Facets::Version("1.19".to_string()))
.or(Facets::Version("1.22".to_string())),
)
.and(FacetsDisjunction::new_with(Facets::ProjectType(
"modpack".to_string(),
)))
.and(
FacetsDisjunction::new_with(Facets::Categories("technology".to_string()))
.or(Facets::Categories("adventure".to_string())),
);
let url = SearchBuilder::new()
.facets(facets)
.search_type(QueryBuilder::new().offset(10).limit(5).build())
.build_url();
assert_eq!(
"https://api.modrinth.com/v2/search?limit=5&offset=10&facets=[\
[\"versions:1.19\",\"versions:1.22\"],\
[\"project_type:modpack\"],\
[\"categories:technology\",\"categories:adventure\"]\
]",
url
);
}
#[test]
pub fn search_builder_projects() {
let url = SearchBuilder::new()
.search_type(SearchType::MultiProject {
ids: vec!["AAA", "BBB"],
})
.build_url();
assert_eq!(
"https://api.modrinth.com/v2/projects?ids=[\"AAA\",\"BBB\"]",
url
)
}
#[test]
pub fn search_builder_project_versions() {
let url = SearchBuilder::new()
.search_type(SearchType::ProjectVersion {
id: "Jw3Wx1KR".to_string(),
loaders: None,
})
.game_versions(["1.18", "1.18.2"])
.build_url();
assert_eq!(
"https://api.modrinth.com/v2/project/Jw3Wx1KR/version?game_versions=[\"1.18\",\"1.18.2\"]",
url
);
}
#[test]
pub fn search_builder_project_versions_one_loader() {
let url = SearchBuilder::new()
.search_type(SearchType::ProjectVersion {
id: "Jw3Wx1KR".to_string(),
loaders: Some(vec!["fabric".to_string()]),
})
.game_versions(["1.18", "1.18.2"])
.build_url();
assert_eq!(
"https://api.modrinth.com/v2/project/Jw3Wx1KR/version?game_versions=[\"1.18\",\"1.18.2\"]&loaders=[\"fabric\"]",
url
);
}
#[test]
pub fn search_builder_project_versions_two_loaders() {
let url = SearchBuilder::new()
.search_type(SearchType::ProjectVersion {
id: "Jw3Wx1KR".to_string(),
loaders: Some(vec!["fabric".to_string(), "quilt".to_string()]),
})
.game_versions(["1.18", "1.18.2"])
.build_url();
assert_eq!(
"https://api.modrinth.com/v2/project/Jw3Wx1KR/version?game_versions=[\"1.18\",\"1.18.2\"]&loaders=[\"fabric\",\"quilt\"]",
url
);
}
#[test]
pub fn collect_conj() {
let conj: FacetsConjunction = ["Technology", "Magic", "Adventure"]
.iter()
.map(|c| FacetsDisjunction::new_with(Facets::Categories(c.to_string())))
.collect();
let url = SearchBuilder::new()
.facets(conj)
.search_type(SearchType::Search { query: "".into(), limit: None, offset: None })
.build_url();
assert_eq!(
"https://api.modrinth.com/v2/search?facets=[[\"categories:Technology\"],[\"categories:Magic\"],[\"categories:Adventure\"]]",
url
);
}
#[test]
pub fn fold_disj() {
let disj = ["Technology", "Magic", "Adventure"]
.iter()
.fold(FacetsDisjunction::new(), |acc,x| acc.or(Facets::Categories(x.to_string())));
let url = SearchBuilder::new()
.facets(FacetsConjunction::new_with(disj))
.search_type(SearchType::Search { query: "".into(), limit: None, offset: None })
.build_url();
assert_eq!(
"https://api.modrinth.com/v2/search?facets=[[\"categories:Technology\",\"categories:Magic\",\"categories:Adventure\"]]",
url
);
}
}