From 9ee271758ae75111b58d1e8eb40726f10427608e Mon Sep 17 00:00:00 2001 From: Fleeym <61891787+Fleeym@users.noreply.github.com> Date: Sat, 3 Aug 2024 00:14:34 +0300 Subject: [PATCH] Add support for the 'links' key in mod.json (#29) * feat(links): add saving of links * feat(links): add links to mod payloads * why not * fix(links): handle some edge cases with updates --- ...20240729184358_add_links_for_mods.down.sql | 3 + .../20240729184358_add_links_for_mods.up.sql | 10 + src/types/mod_json.rs | 36 ++++ src/types/models/mod.rs | 3 +- src/types/models/mod_entity.rs | 58 +++++- src/types/models/mod_link.rs | 189 ++++++++++++++++++ 6 files changed, 288 insertions(+), 11 deletions(-) create mode 100644 migrations/20240729184358_add_links_for_mods.down.sql create mode 100644 migrations/20240729184358_add_links_for_mods.up.sql create mode 100644 src/types/models/mod_link.rs diff --git a/migrations/20240729184358_add_links_for_mods.down.sql b/migrations/20240729184358_add_links_for_mods.down.sql new file mode 100644 index 0000000..8a584cb --- /dev/null +++ b/migrations/20240729184358_add_links_for_mods.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here + +DROP TABLE mod_links; \ No newline at end of file diff --git a/migrations/20240729184358_add_links_for_mods.up.sql b/migrations/20240729184358_add_links_for_mods.up.sql new file mode 100644 index 0000000..830802f --- /dev/null +++ b/migrations/20240729184358_add_links_for_mods.up.sql @@ -0,0 +1,10 @@ +-- Add up migration script here + +CREATE TABLE mod_links ( + mod_id TEXT PRIMARY KEY NOT NULL, + community TEXT, + homepage TEXT, + source TEXT, + + FOREIGN KEY (mod_id) REFERENCES mods(id) +); \ No newline at end of file diff --git a/src/types/mod_json.rs b/src/types/mod_json.rs index e0c898c..aa6493f 100644 --- a/src/types/mod_json.rs +++ b/src/types/mod_json.rs @@ -6,6 +6,7 @@ use image::{ DynamicImage, GenericImageView, ImageEncoder, }; use regex::Regex; +use reqwest::Url; use semver::Version; use serde::Deserialize; use std::io::BufReader; @@ -59,6 +60,14 @@ pub struct ModJson { pub changelog: Option, pub dependencies: Option>, pub incompatibilities: Option>, + pub links: Option, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct ModJsonLinks { + pub community: Option, + pub homepage: Option, + pub source: Option, } #[derive(Deserialize, Debug)] @@ -366,6 +375,33 @@ impl ModJson { "Mod id too long (max 64 characters)".to_string(), )); } + + if let Some(l) = &self.links { + if let Some(community) = &l.community { + if let Err(e) = Url::parse(community) { + return Err(ApiError::BadRequest(format!( + "Invalid community URL: {}. Reason: {}", + community, e + ))); + } + } + if let Some(homepage) = &l.homepage { + if let Err(e) = Url::parse(homepage) { + return Err(ApiError::BadRequest(format!( + "Invalid homepage URL: {}. Reason: {}", + homepage, e + ))); + } + } + if let Some(source) = &l.source { + if let Err(e) = Url::parse(source) { + return Err(ApiError::BadRequest(format!( + "Invalid source URL: {}. Reason: {}", + source, e + ))); + } + } + } Ok(()) } } diff --git a/src/types/models/mod.rs b/src/types/models/mod.rs index 13125fc..142755b 100644 --- a/src/types/models/mod.rs +++ b/src/types/models/mod.rs @@ -5,7 +5,8 @@ pub mod github_login_attempt; pub mod incompatibility; pub mod mod_entity; pub mod mod_gd_version; +pub mod mod_link; pub mod mod_version; pub mod mod_version_status; -pub mod tag; pub mod stats; +pub mod tag; diff --git a/src/types/models/mod_entity.rs b/src/types/models/mod_entity.rs index 5d8f231..b550f7d 100644 --- a/src/types/models/mod_entity.rs +++ b/src/types/models/mod_entity.rs @@ -5,7 +5,7 @@ use crate::{ }, types::{ api::{ApiError, PaginatedData}, - mod_json::{self, ModJson}, + mod_json::{self, ModJson, ModJsonLinks}, models::{ mod_version::ModVersion, mod_version_status::ModVersionStatusEnum, }, @@ -23,11 +23,7 @@ use sqlx::{ use std::{collections::HashMap, io::{Cursor, Read}, str::FromStr}; use super::{ - dependency::ResponseDependency, - developer::{Developer, FetchedDeveloper}, - incompatibility::{Replacement, ResponseIncompatibility}, - mod_gd_version::{DetailedGDVersion, GDVersionEnum, ModGDVersion, VerPlatform}, - tag::Tag, + dependency::ResponseDependency, developer::{Developer, FetchedDeveloper}, incompatibility::{Replacement, ResponseIncompatibility}, mod_gd_version::{DetailedGDVersion, GDVersionEnum, ModGDVersion, VerPlatform}, mod_link::ModLinks, tag::Tag }; #[derive(Serialize, Debug, sqlx::FromRow)] @@ -43,6 +39,7 @@ pub struct Mod { pub changelog: Option, pub created_at: String, pub updated_at: String, + pub links: Option } #[derive(Serialize, Debug)] @@ -397,6 +394,7 @@ impl Mod { let ids: Vec<_> = records.iter().map(|x| x.id.clone()).collect(); let versions = ModVersion::get_latest_for_mods(pool, ids.clone(), query.gd, platforms, query.geode.as_ref()).await?; let developers = Developer::fetch_for_mods(&ids, pool).await?; + let links = ModLinks::fetch_for_mods(&ids, pool).await?; let mut mod_version_ids: Vec = vec![]; for (_, mod_version) in versions.iter() { mod_version_ids.push(mod_version.id); @@ -414,6 +412,8 @@ impl Mod { let devs = developers.get(&x.id).cloned().unwrap_or_default(); let tags = tags.get(&x.id).cloned().unwrap_or_default(); + let links = links.iter().find(|link| link.mod_id == x.id).cloned(); + Mod { id: x.id.clone(), repository: x.repository.clone(), @@ -426,6 +426,7 @@ impl Mod { updated_at: x.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true), about: None, changelog: None, + links } }) .collect(); @@ -440,6 +441,7 @@ impl Mod { let ids: Vec<_> = records.iter().map(|x| x.id.clone()).collect(); let versions = ModVersion::get_pending_for_mods(&ids, pool).await?; let developers = Developer::fetch_for_mods(&ids, pool).await?; + let links = ModLinks::fetch_for_mods(&ids, pool).await?; let mut mod_version_ids: Vec = vec![]; for (_, mod_version) in versions.iter() { mod_version_ids.append(&mut mod_version.iter().map(|x| x.id).collect()); @@ -457,6 +459,8 @@ impl Mod { let devs = developers.get(&x.id).cloned().unwrap_or_default(); let tags = tags.get(&x.id).cloned().unwrap_or_default(); + let links = links.iter().find(|link| link.mod_id == x.id).cloned(); + Mod { id: x.id.clone(), repository: x.repository.clone(), @@ -469,6 +473,7 @@ impl Mod { updated_at: x.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true), about: x.about, changelog: x.changelog, + links } }) .collect::>(); @@ -622,10 +627,11 @@ impl Mod { info: Some(x.info.clone()), }) .collect(); - let ids = versions.iter().map(|x| x.id).collect(); - let gd = ModGDVersion::get_for_mod_versions(&ids, pool).await?; - let tags = Tag::get_tags_for_mod(id, pool).await?; - let devs = Developer::fetch_for_mod(id, pool).await?; + let ids: Vec = versions.iter().map(|x| x.id).collect(); + let gd: HashMap = ModGDVersion::get_for_mod_versions(&ids, pool).await?; + let tags: Vec = Tag::get_tags_for_mod(id, pool).await?; + let devs: Vec = Developer::fetch_for_mod(id, pool).await?; + let links: Option = ModLinks::fetch(id, pool).await?; for i in &mut versions { let gd_versions = gd.get(&i.id).cloned().unwrap_or_default(); @@ -648,6 +654,7 @@ impl Mod { .to_rfc3339_opts(SecondsFormat::Secs, true), about: records[0].about.clone(), changelog: records[0].changelog.clone(), + links }; Ok(Some(mod_entity)) } @@ -673,6 +680,19 @@ impl Mod { let dev_verified = developer.verified; Mod::create(json, developer, pool).await?; + if let Some(l) = &json.links { + if l.community.is_some() + || l.homepage.is_some() + || l.source.is_some() { + ModLinks::upsert_for_mod( + &json.id, + l.community.clone(), + l.homepage.clone(), + l.source.clone(), + pool + ).await?; + } + } ModVersion::create_from_json(json, dev_verified, pool).await?; Ok(()) } @@ -1000,6 +1020,24 @@ impl Mod { } } + let links = ModLinks::fetch(&json.id, pool).await?; + + if links.is_some() || json.links.is_some() { + let links = json.links.clone().unwrap_or(ModJsonLinks { + community: None, + source: None, + homepage: None + }); + ModLinks::upsert_for_mod( + &json.id, + links.community, + links.homepage, + links.source, + pool + ).await?; + } + + Ok(()) } diff --git a/src/types/models/mod_link.rs b/src/types/models/mod_link.rs new file mode 100644 index 0000000..7cf2484 --- /dev/null +++ b/src/types/models/mod_link.rs @@ -0,0 +1,189 @@ +use serde::Serialize; +use sqlx::PgConnection; + +use crate::types::api::ApiError; + +#[derive(Serialize, Debug, Clone)] +pub struct ModLinks { + pub mod_id: String, + pub community: Option, + pub homepage: Option, + pub source: Option, +} + +impl ModLinks { + pub async fn fetch( + mod_id: &str, + pool: &mut PgConnection, + ) -> Result, ApiError> { + match sqlx::query_as!( + ModLinks, + "SELECT + mod_id, community, homepage, source + FROM mod_links + WHERE mod_id = $1", + mod_id + ) + .fetch_optional(pool) + .await + { + Ok(r) => Ok(r), + Err(e) => { + log::error!("Failed to fetch mod links for mod {}. Error: {}", mod_id, e); + Err(ApiError::DbError) + } + } + } + + pub async fn fetch_for_mods( + mod_ids: &Vec, + pool: &mut PgConnection, + ) -> Result, ApiError> { + if mod_ids.is_empty() { + return Ok(vec![]); + } + + match sqlx::query_as!( + ModLinks, + "SELECT + mod_id, community, homepage, source + FROM mod_links + WHERE mod_id = ANY($1)", + mod_ids + ) + .fetch_all(pool) + .await + { + Err(e) => { + log::error!("Failed to fetch mod links for multiple mods. Error: {}", e); + Err(ApiError::DbError) + } + Ok(r) => Ok(r), + } + } + + pub async fn upsert_for_mod( + mod_id: &str, + community: Option, + homepage: Option, + source: Option, + pool: &mut PgConnection, + ) -> Result, ApiError> { + if ModLinks::exists(mod_id, pool).await? { + return ModLinks::update_for_mod(mod_id, community, homepage, source, pool).await; + } + + match sqlx::query!( + "INSERT INTO mod_links + (mod_id, community, homepage, source) + VALUES + ($1, $2, $3, $4)", + mod_id, + community, + homepage, + source + ) + .execute(pool) + .await + { + Ok(_) => Ok(Some(ModLinks { + mod_id: mod_id.to_string(), + community, + homepage, + source, + })), + Err(e) => { + log::error!("Failed to create mod link for {}. Error: {}", mod_id, e); + Err(ApiError::DbError) + } + } + } + + pub async fn exists(mod_id: &str, pool: &mut PgConnection) -> Result { + match sqlx::query!( + "SELECT mod_id + FROM mod_links + WHERE mod_id = $1", + mod_id + ) + .fetch_optional(pool) + .await + { + Ok(r) => Ok(r.is_some()), + Err(e) => { + log::error!( + "Failed to check if mod links exist for {}. Error: {}", + mod_id, + e + ); + Err(ApiError::DbError) + } + } + } + + async fn update_for_mod( + mod_id: &str, + community: Option, + homepage: Option, + source: Option, + pool: &mut PgConnection, + ) -> Result, ApiError> { + if community.is_none() && homepage.is_none() && source.is_none() { + ModLinks::delete_for_mod(mod_id, pool).await?; + return Ok(None); + } + + match sqlx::query!( + "UPDATE mod_links + SET community = $1, + homepage = $2, + source = $3 + WHERE mod_id = $4", + community, + homepage, + source, + mod_id + ) + .execute(pool) + .await + { + Err(e) => { + log::error!("Failed to update mod links for {}. Error: {}", mod_id, e); + Err(ApiError::DbError) + } + Ok(r) => { + if r.rows_affected() == 0 { + log::error!( + "Failed to update mod links for {}. No rows affected.", + mod_id + ); + Err(ApiError::DbError) + } else { + Ok(Some(ModLinks { + mod_id: mod_id.to_string(), + community, + homepage, + source, + })) + } + } + } + } + + async fn delete_for_mod(mod_id: &str, pool: &mut PgConnection) -> Result<(), ApiError> { + match sqlx::query!( + "DELETE FROM mod_links + WHERE mod_id = $1", + mod_id + ) + .execute(pool) + .await + { + Ok(_) => Ok(()), + Err(e) => { + log::error!("Failed to delete mod_links for {}. Error: {}", mod_id, e); + Err(ApiError::DbError) + } + } + } +}