Implement more database methods and basic API routes (#50)

* feat: Implement more database methods & add mod and version routes

* feat: Implement deleting mods/versions & implement categories

* feat: Implement routes for categories, game versions & loaders

* feat: Reorganize API routes in a (hopefully) usable way
This commit is contained in:
Aeledfyr
2020-08-12 14:54:03 -05:00
committed by GitHub
parent e2bf474332
commit 781f0c843e
20 changed files with 2146 additions and 125 deletions

View File

@@ -0,0 +1,396 @@
use super::ids::*;
use super::DatabaseError;
use futures::TryStreamExt;
pub struct Loader {
pub id: LoaderId,
pub loader: String,
}
pub struct GameVersion {
pub id: GameVersionId,
pub version: String,
}
pub struct Category {
pub id: CategoryId,
pub category: String,
}
pub struct CategoryBuilder<'a> {
pub name: Option<&'a str>,
}
impl Category {
pub fn builder() -> CategoryBuilder<'static> {
CategoryBuilder { name: None }
}
pub async fn get_id<'a, E>(name: &str, exec: E) -> Result<Option<CategoryId>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(DatabaseError::InvalidIdentifier(name.to_string()));
}
let result = sqlx::query!(
"
SELECT id FROM categories
WHERE category = $1
",
name
)
.fetch_optional(exec)
.await?;
Ok(result.map(|r| CategoryId(r.id)))
}
pub async fn get_name<'a, E>(id: CategoryId, exec: E) -> Result<String, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT category FROM categories
WHERE id = $1
",
id as CategoryId
)
.fetch_one(exec)
.await?;
Ok(result.category)
}
pub async fn list<'a, E>(exec: E) -> Result<Vec<String>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT category FROM categories
"
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.category)) })
.try_collect::<Vec<String>>()
.await?;
Ok(result)
}
pub async fn remove<'a, E>(name: &str, exec: E) -> Result<Option<()>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
use sqlx::Done;
let result = sqlx::query!(
"
DELETE FROM categories
WHERE category = $1
",
name
)
.execute(exec)
.await?;
if result.rows_affected() == 0 {
// Nothing was deleted
Ok(None)
} else {
Ok(Some(()))
}
}
}
impl<'a> CategoryBuilder<'a> {
/// The name of the category. Must be ASCII alphanumeric or `-`/`_`
pub fn name(mut self, name: &'a str) -> Result<CategoryBuilder<'a>, DatabaseError> {
if name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
Ok(Self { name: Some(name) })
} else {
Err(DatabaseError::InvalidIdentifier(name.to_string()))
}
}
pub async fn insert<'b, E>(self, exec: E) -> Result<CategoryId, DatabaseError>
where
E: sqlx::Executor<'b, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
INSERT INTO categories (category)
VALUES ($1)
RETURNING id
",
self.name
)
.fetch_one(exec)
.await?;
Ok(CategoryId(result.id))
}
}
pub struct LoaderBuilder<'a> {
pub name: Option<&'a str>,
}
impl Loader {
pub fn builder() -> LoaderBuilder<'static> {
LoaderBuilder { name: None }
}
pub async fn get_id<'a, E>(name: &str, exec: E) -> Result<Option<LoaderId>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(DatabaseError::InvalidIdentifier(name.to_string()));
}
let result = sqlx::query!(
"
SELECT id FROM loaders
WHERE loader = $1
",
name
)
.fetch_optional(exec)
.await?;
Ok(result.map(|r| LoaderId(r.id)))
}
pub async fn get_name<'a, E>(id: LoaderId, exec: E) -> Result<String, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT loader FROM loaders
WHERE id = $1
",
id as LoaderId
)
.fetch_one(exec)
.await?;
Ok(result.loader)
}
pub async fn list<'a, E>(exec: E) -> Result<Vec<String>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT loader FROM loaders
"
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.loader)) })
.try_collect::<Vec<String>>()
.await?;
Ok(result)
}
// TODO: remove loaders with mods using them
pub async fn remove<'a, E>(name: &str, exec: E) -> Result<Option<()>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
use sqlx::Done;
let result = sqlx::query!(
"
DELETE FROM loaders
WHERE loader = $1
",
name
)
.execute(exec)
.await?;
if result.rows_affected() == 0 {
// Nothing was deleted
Ok(None)
} else {
Ok(Some(()))
}
}
}
impl<'a> LoaderBuilder<'a> {
/// The name of the loader. Must be ASCII alphanumeric or `-`/`_`
pub fn name(mut self, name: &'a str) -> Result<LoaderBuilder<'a>, DatabaseError> {
if name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
Ok(Self { name: Some(name) })
} else {
Err(DatabaseError::InvalidIdentifier(name.to_string()))
}
}
pub async fn insert<'b, E>(self, exec: E) -> Result<LoaderId, DatabaseError>
where
E: sqlx::Executor<'b, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
INSERT INTO loaders (loader)
VALUES ($1)
RETURNING id
",
self.name
)
.fetch_one(exec)
.await?;
Ok(LoaderId(result.id))
}
}
pub struct GameVersionBuilder<'a> {
pub version: Option<&'a str>,
}
impl GameVersion {
pub fn builder() -> GameVersionBuilder<'static> {
GameVersionBuilder { version: None }
}
pub async fn get_id<'a, E>(
version: &str,
exec: E,
) -> Result<Option<GameVersionId>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
if !version
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
{
return Err(DatabaseError::InvalidIdentifier(version.to_string()));
}
let result = sqlx::query!(
"
SELECT id FROM game_versions
WHERE version = $1
",
version
)
.fetch_optional(exec)
.await?;
Ok(result.map(|r| GameVersionId(r.id)))
}
pub async fn get_name<'a, E>(id: VersionId, exec: E) -> Result<String, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT version FROM game_versions
WHERE id = $1
",
id as VersionId
)
.fetch_one(exec)
.await?;
Ok(result.version)
}
pub async fn list<'a, E>(exec: E) -> Result<Vec<String>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT version FROM game_versions
"
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.version)) })
.try_collect::<Vec<String>>()
.await?;
Ok(result)
}
pub async fn remove<'a, E>(name: &str, exec: E) -> Result<Option<()>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
use sqlx::Done;
let result = sqlx::query!(
"
DELETE FROM game_versions
WHERE version = $1
",
name
)
.execute(exec)
.await?;
if result.rows_affected() == 0 {
// Nothing was deleted
Ok(None)
} else {
Ok(Some(()))
}
}
}
impl<'a> GameVersionBuilder<'a> {
/// The game version. Spaces must be replaced with '_' for it to be valid
pub fn version(mut self, version: &'a str) -> Result<GameVersionBuilder<'a>, DatabaseError> {
if version
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
{
Ok(Self {
version: Some(version),
})
} else {
Err(DatabaseError::InvalidIdentifier(version.to_string()))
}
}
pub async fn insert<'b, E>(self, exec: E) -> Result<GameVersionId, DatabaseError>
where
E: sqlx::Executor<'b, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
INSERT INTO game_versions (version)
VALUES ($1)
RETURNING id
",
self.version
)
.fetch_one(exec)
.await?;
Ok(GameVersionId(result.id))
}
}

View File

@@ -1,5 +1,5 @@
use super::DatabaseError;
use crate::models::ids::random_base62;
use crate::models::ids::random_base62_rng;
use sqlx_macros::Type;
const ID_RETRY_COUNT: usize = 20;
@@ -9,8 +9,10 @@ macro_rules! generate_ids {
$vis async fn $function_name(
con: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<$return_type, DatabaseError> {
use rand::Rng;
let mut rng = rand::thread_rng();
let length = $id_length;
let mut id = random_base62(length);
let mut id = random_base62_rng(&mut rng, length);
let mut retry_count = 0;
// Check if ID is unique
@@ -20,7 +22,7 @@ macro_rules! generate_ids {
.await?;
if results.exists.unwrap_or(true) {
id = random_base62(length);
id = random_base62_rng(&mut rng, length);
} else {
break;
}

View File

@@ -3,6 +3,7 @@
use thiserror::Error;
pub mod categories;
pub mod ids;
pub mod mod_item;
pub mod team_item;
@@ -22,4 +23,9 @@ pub enum DatabaseError {
DatabaseError(#[from] sqlx::error::Error),
#[error("Error while trying to generate random ID")]
RandomIdError,
#[error(
"Invalid identifier: Category/version names must contain only ASCII \
alphanumeric characters or '_-'."
)]
InvalidIdentifier(String),
}

View File

@@ -105,4 +105,175 @@ impl Mod {
Ok(())
}
pub async fn get<'a, 'b, E>(id: ModId, executor: E) -> Result<Option<Self>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT title, description, downloads,
icon_url, body_url, published,
issues_url, source_url, wiki_url,
team_id
FROM mods
WHERE id = $1
",
id as ModId,
)
.fetch_optional(executor)
.await?;
if let Some(row) = result {
Ok(Some(Mod {
id,
team_id: TeamId(row.team_id),
title: row.title,
description: row.description,
downloads: row.downloads,
body_url: row.body_url,
icon_url: row.icon_url,
published: row.published,
issues_url: row.issues_url,
source_url: row.source_url,
wiki_url: row.wiki_url,
}))
} else {
Ok(None)
}
}
pub async fn remove_full<'a, 'b, E>(
id: ModId,
exec: E,
) -> Result<Option<()>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let result = sqlx::query!(
"
SELECT team_id FROM mods WHERE id = $1
",
id as ModId,
)
.fetch_optional(exec)
.await?;
let team_id: TeamId = if let Some(id) = result {
TeamId(id.team_id)
} else {
return Ok(None);
};
sqlx::query!(
"
DELETE FROM mods_categories
WHERE joining_mod_id = $1
",
id as ModId,
)
.execute(exec)
.await?;
use futures::TryStreamExt;
let versions: Vec<VersionId> = sqlx::query!(
"
SELECT id FROM versions
WHERE mod_id = $1
",
id as ModId,
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| VersionId(c.id))) })
.try_collect::<Vec<VersionId>>()
.await?;
for version in versions {
super::Version::remove_full(version, exec).await?;
}
sqlx::query!(
"
DELETE FROM mods
WHERE id = $1
",
id as ModId,
)
.execute(exec)
.await?;
sqlx::query!(
"
DELETE FROM team_members
WHERE team_id = $1
",
team_id as TeamId,
)
.execute(exec)
.await?;
sqlx::query!(
"
DELETE FROM teams
WHERE id = $1
",
team_id as TeamId,
)
.execute(exec)
.await?;
Ok(Some(()))
}
pub async fn get_full<'a, 'b, E>(
id: ModId,
executor: E,
) -> Result<Option<QueryMod>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let result = Self::get(id, executor).await?;
if let Some(inner) = result {
use futures::TryStreamExt;
let categories: Vec<String> = sqlx::query!(
"
SELECT category FROM mods_categories
INNER JOIN categories ON joining_category_id = id
WHERE joining_mod_id = $1
",
id as ModId,
)
.fetch_many(executor)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.category)) })
.try_collect::<Vec<String>>()
.await?;
let versions: Vec<VersionId> = sqlx::query!(
"
SELECT id FROM versions
WHERE mod_id = $1
",
id as ModId,
)
.fetch_many(executor)
.try_filter_map(|e| async { Ok(e.right().map(|c| VersionId(c.id))) })
.try_collect::<Vec<VersionId>>()
.await?;
Ok(Some(QueryMod {
inner,
categories,
versions,
}))
} else {
Ok(None)
}
}
}
pub struct QueryMod {
pub inner: Mod,
pub categories: Vec<String>,
pub versions: Vec<VersionId>,
}

View File

@@ -1,3 +1,4 @@
use super::categories::{GameVersion, Loader};
use super::ids::*;
use super::DatabaseError;
@@ -173,7 +174,118 @@ impl Version {
Ok(())
}
pub async fn get_dependencies<'a, E>(&self, exec: E) -> Result<Vec<VersionId>, sqlx::Error>
// TODO: someone verify this
pub async fn remove_full<'a, E>(id: VersionId, exec: E) -> Result<Option<()>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
use sqlx::Done;
let result = sqlx::query!(
"
SELECT EXISTS(SELECT 1 FROM versions WHERE id = $1)
",
id as VersionId,
)
.fetch_one(exec)
.await?;
if !result.exists.unwrap_or(false) {
return Ok(None);
}
sqlx::query!(
"
DELETE FROM game_versions_versions gvv
WHERE gvv.joining_version_id = $1
",
id as VersionId,
)
.execute(exec)
.await?;
sqlx::query!(
"
DELETE FROM loaders_versions
WHERE loaders_versions.version_id = $1
",
id as VersionId,
)
.execute(exec)
.await?;
use futures::TryStreamExt;
let mut files = sqlx::query!(
"
SELECT files.id, files.url, files.filename FROM files
WHERE files.version_id = $1
",
id as VersionId,
)
.fetch_many(exec)
.try_filter_map(|e| async {
Ok(e.right().map(|c| VersionFile {
id: FileId(c.id),
version_id: id,
url: c.url,
filename: c.filename,
}))
})
.try_collect::<Vec<VersionFile>>()
.await?;
for file in files {
// TODO: store backblaze id in database so that we can delete the files here
// For now, we can't delete the files since we don't have the backblaze id
log::warn!(
"Can't delete version file id: {} (url: {}, name: {})",
file.id.0,
file.url,
file.filename
)
}
sqlx::query!(
"
DELETE FROM hashes
WHERE EXISTS(
SELECT 1 FROM files WHERE
(files.version_id = $1) AND
(hashes.file_id = files.id)
)
",
id as VersionId
)
.execute(exec)
.await?;
sqlx::query!(
"
DELETE FROM files
WHERE files.version_id = $1
",
id as VersionId,
)
.execute(exec)
.await?;
sqlx::query!(
"
DELETE FROM versions WHERE id = $1
",
id as VersionId,
)
.execute(exec)
.await?;
Ok(Some(()))
}
pub async fn get_dependencies<'a, E>(
id: VersionId,
exec: E,
) -> Result<Vec<VersionId>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
@@ -184,7 +296,7 @@ impl Version {
SELECT dependency_id id FROM dependencies
WHERE dependent_id = $1
",
self.id as VersionId,
id as VersionId,
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|v| VersionId(v.id))) })
@@ -193,20 +305,177 @@ impl Version {
Ok(vec)
}
pub async fn get_mod_versions<'a, E>(
mod_id: ModId,
exec: E,
) -> Result<Vec<VersionId>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
use futures::stream::TryStreamExt;
let vec = sqlx::query!(
"
SELECT id FROM versions
WHERE mod_id = $1
",
mod_id as ModId,
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|v| VersionId(v.id))) })
.try_collect::<Vec<VersionId>>()
.await?;
Ok(vec)
}
pub async fn get<'a, 'b, E>(
id: VersionId,
executor: E,
) -> Result<Option<Self>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT v.mod_id, v.name, v.version_number,
v.changelog_url, v.date_published, v.downloads,
v.release_channel
FROM versions v
WHERE v.id = $1
",
id as VersionId,
)
.fetch_optional(executor)
.await?;
if let Some(row) = result {
Ok(Some(Version {
id,
mod_id: ModId(row.mod_id),
name: row.name,
version_number: row.version_number,
changelog_url: row.changelog_url,
date_published: row.date_published,
downloads: row.downloads,
release_channel: ChannelId(row.release_channel),
}))
} else {
Ok(None)
}
}
pub async fn get_full<'a, 'b, E>(
id: VersionId,
executor: E,
) -> Result<Option<QueryVersion>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let result = sqlx::query!(
"
SELECT v.mod_id, v.name, v.version_number,
v.changelog_url, v.date_published, v.downloads,
release_channels.channel
FROM versions v
INNER JOIN release_channels ON v.release_channel = release_channels.id
WHERE v.id = $1
",
id as VersionId,
)
.fetch_optional(executor)
.await?;
if let Some(row) = result {
use futures::TryStreamExt;
use sqlx::Row;
let game_versions: Vec<String> = sqlx::query!(
"
SELECT gv.version FROM game_versions_versions gvv
INNER JOIN game_versions gv ON gvv.game_version_id=gv.id
WHERE gvv.joining_version_id = $1
",
id as VersionId,
)
.fetch_many(executor)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.version)) })
.try_collect::<Vec<String>>()
.await?;
let loaders: Vec<String> = sqlx::query!(
"
SELECT loaders.loader FROM loaders
INNER JOIN loaders_versions ON loaders.id = loaders_versions.loader_id
WHERE loaders_versions.version_id = $1
",
id as VersionId,
)
.fetch_many(executor)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.loader)) })
.try_collect::<Vec<String>>()
.await?;
let mut files = sqlx::query!(
"
SELECT files.id, files.url, files.filename FROM files
WHERE files.version_id = $1
",
id as VersionId,
)
.fetch_many(executor)
.try_filter_map(|e| async {
Ok(e.right().map(|c| QueryFile {
id: FileId(c.id),
url: c.url,
filename: c.filename,
hashes: std::collections::HashMap::new(),
}))
})
.try_collect::<Vec<QueryFile>>()
.await?;
for file in files.iter_mut() {
let mut files = sqlx::query!(
"
SELECT hashes.algorithm, hashes.hash FROM hashes
WHERE hashes.file_id = $1
",
file.id as FileId
)
.fetch_many(executor)
.try_filter_map(|e| async { Ok(e.right().map(|c| (c.algorithm, c.hash))) })
.try_collect::<Vec<(String, Vec<u8>)>>()
.await?;
file.hashes.extend(files);
}
Ok(Some(QueryVersion {
id,
mod_id: ModId(row.mod_id),
name: row.name,
version_number: row.version_number,
changelog_url: row.changelog_url,
date_published: row.date_published,
downloads: row.downloads,
release_channel: row.channel,
files: Vec::<QueryFile>::new(),
loaders,
game_versions,
}))
} else {
Ok(None)
}
}
}
pub struct ReleaseChannel {
pub id: ChannelId,
pub channel: String,
}
pub struct Loader {
pub id: LoaderId,
pub loader: String,
}
pub struct GameVersion {
pub id: GameVersionId,
pub version: String,
}
pub struct VersionFile {
pub id: FileId,
@@ -221,7 +490,24 @@ pub struct FileHash {
pub hash: Vec<u8>,
}
pub struct Category {
pub id: CategoryId,
pub category: String,
pub struct QueryVersion {
pub id: VersionId,
pub mod_id: ModId,
pub name: String,
pub version_number: String,
pub changelog_url: Option<String>,
pub date_published: chrono::DateTime<chrono::Utc>,
pub downloads: i32,
pub release_channel: String,
pub files: Vec<QueryFile>,
pub game_versions: Vec<String>,
pub loaders: Vec<String>,
}
pub struct QueryFile {
pub id: FileId,
pub url: String,
pub filename: String,
pub hashes: std::collections::HashMap<String, Vec<u8>>,
}

View File

@@ -176,10 +176,11 @@ async fn main() -> std::io::Result<()> {
.data(file_host.clone())
.data(indexing_queue.clone())
.service(routes::index_get)
.service(routes::mod_search)
.service(routes::mod_create)
.service(routes::version_create)
.service(routes::upload_file_to_version)
.service(
web::scope("/api/v1/")
.configure(routes::tags_config)
.configure(routes::mods_config),
)
.default_service(web::get().to(routes::not_found))
})
.bind(dotenv::var("BIND_ADDR").unwrap())?

View File

@@ -171,7 +171,7 @@ pub mod base62_impl {
fn parse_base62(string: &str) -> Result<u64, DecodingError> {
let mut num: u64 = 0;
for c in string.chars().rev() {
for c in string.chars() {
let next_digit;
if c.is_ascii_digit() {
next_digit = (c as u8 - b'0') as u64;

View File

@@ -81,20 +81,13 @@ pub struct Version {
/// A single mod file, with a url for the file and the file's hash
#[derive(Serialize, Deserialize)]
pub struct VersionFile {
/// A list of hashes of the file
pub hashes: Vec<FileHash>,
/// A map of hashes of the file. The key is the hashing algorithm
/// and the value is the string version of the hash.
pub hashes: std::collections::HashMap<String, String>,
/// A direct link to the file for downloading it.
pub url: String,
}
/// A hash of a mod's file
#[derive(Serialize, Deserialize)]
pub struct FileHash {
// TODO: decide specific algorithms
/// The hashing algorithm used for this hash; could be "md5", "sha1", etc
pub algorithm: String,
/// The file hash, using the specified algorithm
pub hash: String,
/// A direct link to the file for downloading it.
pub filename: String,
}
#[derive(Serialize, Deserialize, Clone)]

View File

@@ -1,12 +1,64 @@
use actix_web::web;
mod index;
mod mod_creation;
mod mods;
mod not_found;
mod tags;
mod version_creation;
mod versions;
pub use tags::config as tags_config;
pub use self::index::index_get;
pub use self::mod_creation::mod_create;
pub use self::mods::mod_search;
pub use self::not_found::not_found;
pub use self::version_creation::upload_file_to_version;
pub use self::version_creation::version_create;
pub fn mods_config(cfg: &mut web::ServiceConfig) {
cfg.service(mods::mod_search);
cfg.service(mod_creation::mod_create);
cfg.service(
web::scope("mod")
.service(mods::mod_get)
.service(mods::mod_delete)
.service(web::scope("{mod_id}").configure(versions_config)),
);
}
pub fn versions_config(cfg: &mut web::ServiceConfig) {
cfg.service(versions::version_list)
.service(version_creation::version_create)
.service(
web::scope("version")
.service(versions::version_get)
.service(versions::version_delete)
.service(
web::scope("{version_id}").service(version_creation::upload_file_to_version),
),
);
}
#[derive(thiserror::Error, Debug)]
pub enum ApiError {
#[error("Internal server error")]
DatabaseError(#[from] crate::database::models::DatabaseError),
}
impl actix_web::ResponseError for ApiError {
fn status_code(&self) -> actix_web::http::StatusCode {
match self {
ApiError::DatabaseError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> actix_web::web::HttpResponse {
actix_web::web::HttpResponse::build(self.status_code()).json(
crate::models::error::ApiError {
error: match self {
ApiError::DatabaseError(..) => "database_error",
},
description: &self.to_string(),
},
)
}
}

View File

@@ -36,6 +36,12 @@ pub enum CreateError {
InvalidIconFormat(String),
#[error("Error with multipart data: {0}")]
InvalidInput(String),
#[error("Invalid game version: {0}")]
InvalidGameVersion(String),
#[error("Invalid loader: {0}")]
InvalidLoader(String),
#[error("Invalid category: {0}")]
InvalidCategory(String),
}
impl actix_web::ResponseError for CreateError {
@@ -50,6 +56,9 @@ impl actix_web::ResponseError for CreateError {
CreateError::MissingValueError(..) => StatusCode::BAD_REQUEST,
CreateError::InvalidIconFormat(..) => StatusCode::BAD_REQUEST,
CreateError::InvalidInput(..) => StatusCode::BAD_REQUEST,
CreateError::InvalidGameVersion(..) => StatusCode::BAD_REQUEST,
CreateError::InvalidLoader(..) => StatusCode::BAD_REQUEST,
CreateError::InvalidCategory(..) => StatusCode::BAD_REQUEST,
}
}
@@ -65,6 +74,9 @@ impl actix_web::ResponseError for CreateError {
CreateError::MissingValueError(..) => "invalid_input",
CreateError::InvalidIconFormat(..) => "invalid_input",
CreateError::InvalidInput(..) => "invalid_input",
CreateError::InvalidGameVersion(..) => "invalid_input",
CreateError::InvalidLoader(..) => "invalid_input",
CreateError::InvalidCategory(..) => "invalid_input",
},
description: &self.to_string(),
})
@@ -112,7 +124,7 @@ pub async fn undo_uploads(
Ok(())
}
#[post("api/v1/mod")]
#[post("mod")]
pub async fn mod_create(
payload: Multipart,
client: Data<PgPool>,
@@ -256,6 +268,22 @@ async fn mod_create_inner(
VersionType::Alpha => models::ChannelId(5),
};
let mut game_versions = Vec::with_capacity(version_data.game_versions.len());
for v in &version_data.game_versions {
let id = models::categories::GameVersion::get_id(&v.0, &mut *transaction)
.await?
.ok_or_else(|| CreateError::InvalidGameVersion(v.0.clone()))?;
game_versions.push(id);
}
let mut loaders = Vec::with_capacity(version_data.loaders.len());
for l in &version_data.loaders {
let id = models::categories::Loader::get_id(&l.0, &mut *transaction)
.await?
.ok_or_else(|| CreateError::InvalidLoader(l.0.clone()))?;
loaders.push(id);
}
let version = models::version_item::VersionBuilder {
version_id: version_id.into(),
mod_id: mod_id.into(),
@@ -268,9 +296,8 @@ async fn mod_create_inner(
.iter()
.map(|x| (*x).into())
.collect::<Vec<_>>(),
// TODO: add game_versions and loaders info
game_versions: vec![],
loaders: vec![],
game_versions,
loaders,
release_channel,
};
@@ -329,6 +356,14 @@ async fn mod_create_inner(
)));
};
let mut categories = Vec::with_capacity(create_data.categories.len());
for category in &create_data.categories {
let id = models::categories::Category::get_id(&category, &mut *transaction)
.await?
.ok_or_else(|| CreateError::InvalidCategory(category.clone()))?;
categories.push(id);
}
let body_url = format!("data/{}/body.md", mod_id);
let upload_data = file_host
@@ -367,8 +402,7 @@ async fn mod_create_inner(
source_url: create_data.source_url,
wiki_url: create_data.wiki_url,
// TODO: convert `create_data.categories` from Vec<String> to Vec<CategoryId>
categories: Vec::new(),
categories,
initial_versions: created_versions,
};

View File

@@ -1,11 +1,68 @@
use super::ApiError;
use crate::database;
use crate::models;
use crate::models::mods::SearchRequest;
use crate::search::{search_for_mod, SearchError};
use actix_web::{get, web, HttpResponse};
use actix_web::{delete, get, web, HttpResponse};
use sqlx::PgPool;
#[get("api/v1/mod")]
#[get("mod")]
pub async fn mod_search(
web::Query(info): web::Query<SearchRequest>,
) -> Result<HttpResponse, SearchError> {
let results = search_for_mod(&info).await?;
Ok(HttpResponse::Ok().json(results))
}
#[get("{id}")]
pub async fn mod_get(
info: web::Path<(models::ids::ModId,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id = info.0;
let mod_data = database::models::Mod::get_full(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(data) = mod_data {
let m = data.inner;
let response = models::mods::Mod {
id: m.id.into(),
team: m.team_id.into(),
title: m.title,
description: m.description,
body_url: m.body_url,
published: m.published,
downloads: m.downloads as u32,
categories: data.categories,
versions: data.versions.into_iter().map(|v| v.into()).collect(),
icon_url: m.icon_url,
issues_url: m.issues_url,
source_url: m.source_url,
wiki_url: m.wiki_url,
};
Ok(HttpResponse::Ok().json(response))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
// TODO: This really needs auth
// TODO: The mod remains in meilisearch's index until the index is deleted
#[delete("{id}")]
pub async fn mod_delete(
info: web::Path<(models::ids::ModId,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id = info.0;
let result = database::models::Mod::remove_full(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

153
src/routes/tags.rs Normal file
View File

@@ -0,0 +1,153 @@
use super::ApiError;
use crate::database::models;
use actix_web::{delete, get, put, web, HttpResponse};
use models::categories::{Category, GameVersion, Loader};
use sqlx::PgPool;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/tag/")
.service(category_list)
.service(category_create)
.service(category_delete)
.service(loader_list)
.service(loader_create)
.service(loader_delete)
.service(game_version_list)
.service(game_version_create)
.service(game_version_delete),
);
}
// TODO: searching / filtering? Could be used to implement a live
// searching category list
#[get("category")]
pub async fn category_list(pool: web::Data<PgPool>) -> Result<HttpResponse, ApiError> {
let results = Category::list(&**pool).await?;
Ok(HttpResponse::Ok().json(results))
}
// At some point this may take more info, but it should be able to
// remain idempotent
// TODO: don't fail if category already exists
#[put("category/{name}")]
pub async fn category_create(
pool: web::Data<PgPool>,
category: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
let name = category.into_inner().0;
let _id = Category::builder().name(&name)?.insert(&**pool).await?;
Ok(HttpResponse::Ok().body(""))
}
#[delete("category/{name}")]
pub async fn category_delete(
pool: web::Data<PgPool>,
category: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
let name = category.into_inner().0;
let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?;
let result = Category::remove(&name, &mut transaction).await?;
transaction
.commit()
.await
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[get("loader")]
pub async fn loader_list(pool: web::Data<PgPool>) -> Result<HttpResponse, ApiError> {
let results = Loader::list(&**pool).await?;
Ok(HttpResponse::Ok().json(results))
}
// At some point this may take more info, but it should be able to
// remain idempotent
// TODO: don't fail if loader already exists
#[put("loader/{name}")]
pub async fn loader_create(
pool: web::Data<PgPool>,
loader: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
let name = loader.into_inner().0;
let _id = Loader::builder().name(&name)?.insert(&**pool).await?;
Ok(HttpResponse::Ok().body(""))
}
#[delete("loader/{name}")]
pub async fn loader_delete(
pool: web::Data<PgPool>,
loader: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
let name = loader.into_inner().0;
let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?;
let result = Loader::remove(&name, &mut transaction).await?;
transaction
.commit()
.await
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[get("game_version")]
pub async fn game_version_list(pool: web::Data<PgPool>) -> Result<HttpResponse, ApiError> {
let results = GameVersion::list(&**pool).await?;
Ok(HttpResponse::Ok().json(results))
}
// At some point this may take more info, but it should be able to
// remain idempotent
#[put("game_version/{name}")]
pub async fn game_version_create(
pool: web::Data<PgPool>,
game_version: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
let name = game_version.into_inner().0;
let _id = GameVersion::builder()
.version(&name)?
.insert(&**pool)
.await?;
Ok(HttpResponse::Ok().body(""))
}
#[delete("game_version/{name}")]
pub async fn game_version_delete(
pool: web::Data<PgPool>,
game_version: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
let name = game_version.into_inner().0;
let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?;
let result = GameVersion::remove(&name, &mut transaction).await?;
transaction
.commit()
.await
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

View File

@@ -14,7 +14,6 @@ use sqlx::postgres::PgPool;
#[derive(Serialize, Deserialize, Clone)]
pub struct InitialVersionData {
pub mod_id: ModId,
pub file_parts: Vec<String>,
pub version_number: String,
pub version_title: String,
@@ -30,8 +29,10 @@ struct InitialFileData {
// TODO: hashes?
}
#[post("api/v1/version")]
// under `/api/v1/mod/{mod_id}`
#[post("version")]
pub async fn version_create(
url_data: actix_web::web::Path<(ModId,)>,
payload: Multipart,
client: Data<PgPool>,
file_host: Data<std::sync::Arc<dyn FileHost + Send + Sync>>,
@@ -39,11 +40,14 @@ pub async fn version_create(
let mut transaction = client.begin().await?;
let mut uploaded_files = Vec::new();
let mod_id = url_data.into_inner().0.into();
let result = version_create_inner(
payload,
&mut transaction,
&***file_host,
&mut uploaded_files,
mod_id,
)
.await;
@@ -69,6 +73,7 @@ async fn version_create_inner(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
file_host: &dyn FileHost,
uploaded_files: &mut Vec<UploadedFile>,
mod_id: models::ModId,
) -> Result<HttpResponse, CreateError> {
let cdn_url = dotenv::var("CDN_URL")?;
@@ -94,12 +99,9 @@ async fn version_create_inner(
initial_version_data = Some(version_create_data);
let version_create_data = initial_version_data.as_ref().unwrap();
// TODO: get mod_id from path (POST `/api/v1/mod/{mod_id}/version`)
let mod_id: ModId = version_create_data.mod_id;
let results = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)",
models::ModId::from(mod_id) as models::ModId
mod_id as models::ModId
)
.fetch_one(&mut *transaction)
.await?;
@@ -113,7 +115,7 @@ async fn version_create_inner(
let results = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM versions WHERE (version_number=$1) AND (mod_id=$2))",
version_create_data.version_number,
models::ModId::from(mod_id) as models::ModId,
mod_id as models::ModId,
)
.fetch_one(&mut *transaction)
.await?;
@@ -125,7 +127,11 @@ async fn version_create_inner(
}
let version_id: VersionId = models::generate_version_id(transaction).await?.into();
let body_url = format!("data/{}/changelogs/{}/body.md", mod_id, version_id);
let body_url = format!(
"data/{}/changelogs/{}/body.md",
ModId::from(mod_id),
version_id
);
let uploaded_text = file_host
.upload_file(
@@ -149,7 +155,7 @@ async fn version_create_inner(
version_builder = Some(VersionBuilder {
version_id: version_id.into(),
mod_id: mod_id.into(),
mod_id,
name: version_create_data.version_title.clone(),
version_number: version_create_data.version_number.clone(),
changelog_url: Some(format!("{}/{}", cdn_url, body_url)),
@@ -246,16 +252,19 @@ async fn version_create_inner(
hashes: file
.hashes
.iter()
.map(|hash| crate::models::mods::FileHash {
algorithm: hash.algorithm.clone(),
// This is a hack since the hashes are currently stored as ASCII
// in the database, but represented here as a Vec<u8>. At some
// point we need to change the hash to be the real bytes in the
// database and add more processing here.
hash: String::from_utf8(hash.hash.clone()).unwrap(),
.map(|hash| {
(
hash.algorithm.clone(),
// This is a hack since the hashes are currently stored as ASCII
// in the database, but represented here as a Vec<u8>. At some
// point we need to change the hash to be the real bytes in the
// database and add more processing here.
String::from_utf8(hash.hash.clone()).unwrap(),
)
})
.collect(),
url: file.url.clone(),
filename: file.filename.clone(),
})
.collect::<Vec<_>>(),
dependencies: version_data_safe.dependencies,
@@ -270,9 +279,10 @@ async fn version_create_inner(
// TODO: file deletion, listing, etc
#[post("api/v1/version/{version_id}/file")]
// under /api/v1/mod/{mod_id}/version/{version_id}
#[post("file")]
pub async fn upload_file_to_version(
url_data: actix_web::web::Path<(VersionId,)>,
url_data: actix_web::web::Path<(ModId, VersionId)>,
payload: Multipart,
client: Data<PgPool>,
file_host: Data<std::sync::Arc<dyn FileHost + Send + Sync>>,
@@ -280,7 +290,9 @@ pub async fn upload_file_to_version(
let mut transaction = client.begin().await?;
let mut uploaded_files = Vec::new();
let version_id = models::VersionId::from(url_data.into_inner().0);
let data = url_data.into_inner();
let mod_id = models::ModId::from(data.0);
let version_id = models::VersionId::from(data.1);
let result = upload_file_to_version_inner(
payload,
@@ -288,6 +300,7 @@ pub async fn upload_file_to_version(
&***file_host,
&mut uploaded_files,
version_id,
mod_id,
)
.await;
@@ -314,6 +327,7 @@ async fn upload_file_to_version_inner(
file_host: &dyn FileHost,
uploaded_files: &mut Vec<UploadedFile>,
version_id: models::VersionId,
mod_id: models::ModId,
) -> Result<HttpResponse, CreateError> {
let cdn_url = dotenv::var("CDN_URL")?;
@@ -339,6 +353,12 @@ async fn upload_file_to_version_inner(
));
}
};
if version.mod_id as u64 != mod_id.0 as u64 {
return Err(CreateError::InvalidInput(
"An invalid version id was supplied".to_string(),
));
}
let mod_id = ModId(version.mod_id as u64);
let version_number = version.version_number;

131
src/routes/versions.rs Normal file
View File

@@ -0,0 +1,131 @@
use super::ApiError;
use crate::database;
use crate::models;
use actix_web::{delete, get, web, HttpResponse};
use sqlx::PgPool;
// TODO: this needs filtering, and a better response type
// Currently it only gives a list of ids, which have to be
// requested manually. This route could give a list of the
// ids as well as the supported versions and loaders, or
// other info that is needed for selecting the right version.
#[get("version")]
pub async fn version_list(
info: web::Path<(models::ids::ModId,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id = info.0.into();
let mod_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1)",
id as database::models::ModId,
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.exists;
if mod_exists.unwrap_or(false) {
let mod_data = database::models::Version::get_mod_versions(id, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let response = mod_data
.into_iter()
.map(|v| v.into())
.collect::<Vec<models::ids::VersionId>>();
Ok(HttpResponse::Ok().json(response))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[get("{version_id}")]
pub async fn version_get(
info: web::Path<(models::ids::ModId, models::ids::VersionId)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id = info.1;
let version_data = database::models::Version::get_full(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(data) = version_data {
use models::mods::VersionType;
if models::ids::ModId::from(data.mod_id) != info.0 {
// Version doesn't belong to that mod
return Ok(HttpResponse::NotFound().body(""));
}
let response = models::mods::Version {
id: data.id.into(),
mod_id: data.mod_id.into(),
name: data.name,
version_number: data.version_number,
changelog_url: data.changelog_url,
date_published: data.date_published,
downloads: data.downloads as u32,
version_type: match data.release_channel.as_str() {
"release" => VersionType::Release,
"beta" => VersionType::Beta,
"alpha" => VersionType::Alpha,
_ => VersionType::Alpha,
},
files: data
.files
.into_iter()
.map(|f| {
models::mods::VersionFile {
url: f.url,
filename: f.filename,
// FIXME: Hashes are currently stored as an ascii byte slice instead
// of as an actual byte array in the database
hashes: f
.hashes
.into_iter()
.map(|(k, v)| Some((k, String::from_utf8(v).ok()?)))
.collect::<Option<_>>()
.unwrap_or_else(Default::default),
}
})
.collect(),
dependencies: Vec::new(), // TODO: dependencies
game_versions: data
.game_versions
.into_iter()
.map(models::mods::GameVersion)
.collect(),
loaders: data
.loaders
.into_iter()
.map(models::mods::ModLoader)
.collect(),
};
Ok(HttpResponse::Ok().json(response))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
// TODO: This really needs auth
#[delete("{version_id}")]
pub async fn version_delete(
info: web::Path<(models::ids::ModId, models::ids::VersionId)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
// TODO: check if the mod exists and matches the version id
let id = info.1;
let result = database::models::Version::remove_full(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

View File

@@ -142,7 +142,7 @@ pub async fn index_curseforge(
}
}
if mod_categories.contains(&"fabric".to_owned()) {
if mod_categories.iter().any(|e| e == "fabric") {
using_fabric = true;
}
@@ -154,7 +154,8 @@ pub async fn index_curseforge(
mod_categories.push(String::from("forge"));
}
if using_fabric {
mod_categories.push(String::from("fabric"));
// The only way this could happen is if "fabric" is already a category
// mod_categories.push(String::from("fabric"));
}
let mut mod_attachments = curseforge_mod.attachments;

View File

@@ -34,7 +34,22 @@ pub async fn index_local(pool: PgPool) -> Result<Vec<UploadSearchMod>, IndexingE
.try_collect::<Vec<String>>()
.await?;
let categories = sqlx::query!(
// TODO: only loaders for recent versions? For mods that have moved from forge to fabric
let loaders: Vec<String> = sqlx::query!(
"
SELECT loaders.loader FROM versions
INNER JOIN loaders_versions lv ON lv.version_id = versions.id
INNER JOIN loaders ON loaders.id = lv.loader_id
WHERE versions.mod_id = $1
",
result.id
)
.fetch_many(&pool)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.loader)) })
.try_collect::<Vec<String>>()
.await?;
let mut categories = sqlx::query!(
"
SELECT c.category
FROM mods_categories mc
@@ -48,6 +63,8 @@ pub async fn index_local(pool: PgPool) -> Result<Vec<UploadSearchMod>, IndexingE
.try_collect::<Vec<String>>()
.await?;
categories.extend(loaders);
let mut icon_url = "".to_string();
if let Some(url) = result.icon_url {