Files
Modrinth-plus/apps/labrinth/src/routes/v2/versions.rs
aecsocket f0224dfff7 Search backend refactor with typesense impl (#5528)
* initial elasticsearch impl

* working elastic cluster

* replace SearchError with ApiError for preparation of search backend

* start factoring meili out to trait

* move meili to backend

* update routes to use search backend trait

* wip

* Update projects.rs

* search backend is only init'd once in config

* wip

* wip: backend agnostic

* change search internal routes to delegate to backend

* initial elasticsearch impl

* fix filtering

* elastic impl

* refactor indexing into its own module

* clean up elastic code

* fix ci

* fix tests

* fix elastic health check

* fix up env rebase

* fix compile

* dummy commit to update github pr

* Fix rebase

* Elastic basic https auth

* Fix duplicate projects showing up

* Fix up tests

* Replace search `ApiErrors` with `eyre::Reports`, propagate background task errors

* clean up agents files

* make index chunk size configurable

* make `match_phrase` in elastic case-insensitive

* use current/next indices and swap between them

* test case for error body

* Fix failing case

* da merge

* factor out common stuff from search backends

* allow fetching hit metadata from search results

* allow customising elasticsearch search config

* bit of docs

* add mappings to indices for elastic

* Implement Typesense

* wip

* fix up some sort fields stuff

* use different approach to filterable field sets

* remove a bunch of search fields which weren't used for filtering

* bucket text matches

* Bucketing by text_match for typesense

* fix tombi lint

* fix some sentry errors and dont prioritise 2+ term matches

* tweak ts query settings

* expose some more search settings

* query sort changes

* small fixes

* should fix pagination stuff

* fix healthcheck maybe

* ragebait ci

* tests

* tests

* revert environment
2026-03-12 18:58:55 +01:00

374 lines
11 KiB
Rust

use std::collections::HashMap;
use super::ApiError;
use crate::database::PgPool;
use crate::database::redis::RedisPool;
use crate::models;
use crate::models::ids::VersionId;
use crate::models::projects::{
Dependency, FileType, Version, VersionStatus, VersionType,
};
use crate::models::v2::projects::LegacyVersion;
use crate::queue::session::AuthQueue;
use crate::routes::{v2_reroute, v3};
use crate::search::SearchBackend;
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
use serde::{Deserialize, Serialize};
use validator::Validate;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(versions_get);
cfg.service(super::version_creation::version_create);
cfg.service(
web::scope("version")
.service(version_get)
.service(version_delete)
.service(version_edit)
.service(super::version_creation::upload_file_to_version),
);
}
#[derive(Serialize, Deserialize, Clone)]
pub struct VersionListFilters {
pub game_versions: Option<String>,
pub loaders: Option<String>,
pub featured: Option<bool>,
pub version_type: Option<VersionType>,
pub limit: Option<usize>,
pub offset: Option<usize>,
#[serde(default = "default_true")]
pub include_changelog: bool,
}
fn default_true() -> bool {
true
}
#[get("version")]
pub async fn version_list(
req: HttpRequest,
info: web::Path<(String,)>,
web::Query(filters): web::Query<VersionListFilters>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let loaders = if let Some(loaders) = filters.loaders {
if let Ok(mut loaders) = serde_json::from_str::<Vec<String>>(&loaders) {
loaders.push("mrpack".to_string());
Some(loaders)
} else {
None
}
} else {
None
};
let loader_fields = if let Some(game_versions) = filters.game_versions {
// TODO: extract this logic which is similar to the other v2->v3 version_file functions
let mut loader_fields = HashMap::new();
serde_json::from_str::<Vec<String>>(&game_versions)
.ok()
.and_then(|versions| {
let mut game_versions: Vec<serde_json::Value> = vec![];
for gv in versions {
game_versions.push(serde_json::json!(gv.clone()));
}
loader_fields
.insert("game_versions".to_string(), game_versions);
if let Some(ref loaders) = loaders {
loader_fields.insert(
"loaders".to_string(),
loaders
.iter()
.map(|x| serde_json::json!(x.clone()))
.collect(),
);
}
serde_json::to_string(&loader_fields).ok()
})
} else {
None
};
let filters = v3::versions::VersionListFilters {
loader_fields,
loaders: loaders.and_then(|x| serde_json::to_string(&x).ok()),
featured: filters.featured,
version_type: filters.version_type,
limit: filters.limit,
offset: filters.offset,
include_changelog: filters.include_changelog,
};
let response = v3::versions::version_list_internal(
req,
info,
web::Query(filters),
pool,
redis,
session_queue,
)
.await
.or_else(v2_reroute::flatten_404_error)?;
// Convert response to V2 format
match v2_reroute::extract_ok_json::<Vec<Version>>(response).await {
Ok(versions) => {
let v2_versions = versions
.into_iter()
.map(LegacyVersion::from)
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(v2_versions))
}
Err(response) => Ok(response),
}
}
// Given a project ID/slug and a version slug
#[get("version/{slug}")]
pub async fn version_project_get(
req: HttpRequest,
info: web::Path<(String, String)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner();
let response = v3::versions::version_project_get_helper(
req,
id,
pool,
redis,
session_queue,
)
.await
.or_else(v2_reroute::flatten_404_error)?;
// Convert response to V2 format
match v2_reroute::extract_ok_json::<Version>(response).await {
Ok(version) => {
let v2_version = LegacyVersion::from(version);
Ok(HttpResponse::Ok().json(v2_version))
}
Err(response) => Ok(response),
}
}
#[derive(Serialize, Deserialize)]
pub struct VersionIds {
pub ids: String,
#[serde(default = "default_true")]
pub include_changelog: bool,
}
#[get("versions")]
pub async fn versions_get(
req: HttpRequest,
web::Query(ids): web::Query<VersionIds>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let ids = v3::versions::VersionIds {
ids: ids.ids,
include_changelog: ids.include_changelog,
};
let response = v3::versions::versions_get(
req,
web::Query(ids),
pool,
redis,
session_queue,
)
.await
.or_else(v2_reroute::flatten_404_error)?;
// Convert response to V2 format
match v2_reroute::extract_ok_json::<Vec<Version>>(response).await {
Ok(versions) => {
let v2_versions = versions
.into_iter()
.map(LegacyVersion::from)
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(v2_versions))
}
Err(response) => Ok(response),
}
}
#[get("{version_id}")]
pub async fn version_get(
req: HttpRequest,
info: web::Path<(models::ids::VersionId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let response =
v3::versions::version_get_helper(req, id, pool, redis, session_queue)
.await
.map(|b| HttpResponse::Ok().json(b))
.or_else(v2_reroute::flatten_404_error)?;
// Convert response to V2 format
match v2_reroute::extract_ok_json::<Version>(response).await {
Ok(version) => {
let v2_version = LegacyVersion::from(version);
Ok(HttpResponse::Ok().json(v2_version))
}
Err(response) => Ok(response),
}
}
#[derive(Serialize, Deserialize, Validate)]
pub struct EditVersion {
#[validate(
length(min = 1, max = 64),
custom(function = "crate::util::validate::validate_name")
)]
pub name: Option<String>,
#[validate(
length(min = 1, max = 32),
regex(path = *crate::util::validate::RE_URL_SAFE)
)]
pub version_number: Option<String>,
#[validate(length(max = 65536))]
pub changelog: Option<String>,
pub version_type: Option<models::projects::VersionType>,
#[validate(
length(min = 0, max = 4096),
custom(function = "crate::util::validate::validate_deps")
)]
pub dependencies: Option<Vec<Dependency>>,
pub game_versions: Option<Vec<String>>,
pub loaders: Option<Vec<models::projects::Loader>>,
pub featured: Option<bool>,
pub downloads: Option<u32>,
pub status: Option<VersionStatus>,
pub file_types: Option<Vec<EditVersionFileType>>,
}
#[derive(Serialize, Deserialize)]
pub struct EditVersionFileType {
pub algorithm: String,
pub hash: String,
pub file_type: Option<FileType>,
}
#[patch("{id}")]
pub async fn version_edit(
req: HttpRequest,
info: web::Path<(VersionId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
new_version: web::Json<EditVersion>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let new_version = new_version.into_inner();
let mut fields = HashMap::new();
if new_version.game_versions.is_some() {
fields.insert(
"game_versions".to_string(),
serde_json::json!(new_version.game_versions),
);
}
// Get the older version to get info from
let old_version = match v3::versions::version_get_helper(
req.clone(),
(*info).0,
pool.clone(),
redis.clone(),
session_queue.clone(),
)
.await
{
Ok(resp) => resp,
Err(ApiError::NotFound) => return Ok(HttpResponse::NotFound().body("")),
Err(err) => return Err(err),
};
let old_version = match v2_reroute::extract_ok_json::<Version>(
HttpResponse::Ok().json(old_version.0),
)
.await
{
Ok(version) => version,
Err(response) => return Ok(response),
};
// If this has 'mrpack_loaders' as a loader field previously, this is a modpack.
// Therefore, if we are modifying the 'loader' field in this case,
// we are actually modifying the 'mrpack_loaders' loader field
let mut loaders = new_version.loaders.clone();
if old_version.fields.contains_key("mrpack_loaders")
&& new_version.loaders.is_some()
{
fields.insert(
"mrpack_loaders".to_string(),
serde_json::json!(new_version.loaders),
);
loaders = None;
}
let new_version = v3::versions::EditVersion {
name: new_version.name,
version_number: new_version.version_number,
changelog: new_version.changelog,
version_type: new_version.version_type,
dependencies: new_version.dependencies,
loaders,
featured: new_version.featured,
downloads: new_version.downloads,
status: new_version.status,
file_types: new_version.file_types.map(|v| {
v.into_iter()
.map(|evft| v3::versions::EditVersionFileType {
algorithm: evft.algorithm,
hash: evft.hash,
file_type: evft.file_type,
})
.collect::<Vec<_>>()
}),
ordering: None,
fields,
};
let response = v3::versions::version_edit(
req,
info,
pool,
redis,
web::Json(serde_json::to_value(new_version)?),
session_queue,
)
.await
.or_else(v2_reroute::flatten_404_error)?;
Ok(response)
}
#[delete("{version_id}")]
pub async fn version_delete(
req: HttpRequest,
info: web::Path<(VersionId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
search_backend: web::Data<dyn SearchBackend>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so we don't need to convert the response
v3::versions::version_delete(
req,
info,
pool,
redis,
session_queue,
search_backend,
)
.await
.or_else(v2_reroute::flatten_404_error)
}