feat: linked server instances (#5221)

* ping queue with tests

* mc ping server info + timeout

* sqlx prepare

* tombi fmt

* tombi fmt

* allow querying server ping data

* fix shear

* wip: resolve comments with pings

* Switch to Redis for server pings

* tombi fmt

* fix compile error

* clear cache on project ping, add server store link

* Schema changes

* Improve server messages for app pinging

* synthetic server project version for search indexing

* wip: clean up server ping, background tasks

* fix migration to sync with main, propagate background task errors

* wip: server modpack content query, components in search

* wip: massive component query refactor

* fix more defaults stuff

* sqlx

* fix serde deser flatten

* fix search indexing not showing fields

* remove leftover prompt

* fix import

* add diff detection for version dependencies without version_id/project_id

* move servers tab to end

* hide app nav tabs if only one tab

* fix undefined property

* on click link for server side bar info

* show recommended & supported versions for vanilla

* fix how install.js installs instance with modpack content title instead of server project title and dont fetch icon when installing to existing instance

* use large play button instance

* show update success instead of launching right into the game

* add global installing server project state

* add comment

* small change: open discover to modpack

* implement ping server projects for latency in app

* add projectV3 to nag context for moderation package

* fix play server project button when instance is launched

* add ping to project header

* wip: server verified plays

* server verified plays compiling

* queue up server plays in batches

* report server plays improved in frontend

* fixes to tracking server joins

* fix: server project detection to do loose null check

* fix server projects showing license

* fix empty server info card

* fix server projects links title

* Fix backend impl for server player count analytics

* fix: allow for links to be set to empty

* hook up server recent plays

* cargo sqlx prepare

* add project sidebar stories

* feat: update project sidebar server info card to new design

* update server project header and project card

* feat: add hide label for project cards

* feat: add tags sidebar card

* small fix to keep color consistent

* fix: remove required content tab from server project page

* many small fixes

* handle locking server instance content

* fix hiding modal after saving server compatibility version

* copy content card item and table from content tab update branch

* fix nav tabs active tag

* fix switching between server instance vs regular instance persisted invalid state

* fix a lot of the bugginess of navtabs when theres hidden/shown tabs between instances. match frontend nav tabs

* hook up backend searchfor frontend in websiet

* fix: server project card tags

* hook up search v3 in app backend for app frontend

* Don't return missing components in project query

* Add game versions to server filters

* move reporting server joins to backend

* send account UUID along with server play analytics

* update java server ping schema

* feat: implement use server search for search sorting and filter facets

* pnpm prepr

* fix game version filter facet

* fix: allow java and bedrock addresses to be deleted

* feat: hook up languages

* Default deserialize `ProjectSerial`

* feat: show server project tags

* small fix on languages multi select

* also default java server content

* fix: update compatibility modal not closing after successful upload

* remove play button in website discovery for servers

* reenable fence in app backend

* update online/offline tag

* add online status indicator pulsing

* revert pulsing

* disable link for custom modpack project and show tooltip

* change modpack to modded type

* update ip address entire button to be clickable

* polish server info card styles

* make offline tag red and properly hook up online tag

* move server related settings into own tab

* fix setting project compatibility resets unsaved changes

* fix javaServerPatchaData wiping content field

* updates to compatibility card, add download button and display supported versions better

* fix unsaved changes popup for tags

* remove console.log

* fix incorrect project type in projects in dashboard

* fix: savable.ts to reset currentValues to data() after save

* upload server banner as gallery image with title == "__mc_server_banner__" and filter it from frontend gallery

* fix error handling and helper text copy

* ensure gallery banners are filtered in app backend gallery display

* add grouped filters for search

* add query params for server search

* feat: deep linking to open server project page then open install to play

* fix search in app frontend

* fix: server project showing offline

* fix: profile create error app backend

Here's what was happening and the fix:

Root cause: In create.rs:107, profile_create assumed the icon_path parameter was always a local filename relative to the caches directory. It did caches_dir().join(icon) which produced a path like ...\caches\https://staging-cdn.modrinth.com/... — the colons in https:// are illegal in Windows paths (OS error 123).

The frontend's installServerProject and createVanillaInstance in install.js:290 both pass project.icon_url (a full URL) directly as the icon parameter.

Fix: Modified profile_create to detect when the icon parameter is a URL (starts with http:// or https://). When it is, it downloads the icon via fetch(), extracts the filename from the URL path, and passes the downloaded bytes and filename to set_icon() which hashes and caches it properly. The existing local-file path continues to work as before.

* pass undefined instead of unknown for modpack content modal

* fix: wrong way to determine offline status

* delete required content page placeholder

* fix: redirect running function instead of passing function

* add in wiki page

* fix diffs which have unknown project/filename

* pnpm prepr

* feat: add handling for "stop" instance state for server project card and page play button

* fix updating modpack shouldn't launch right into game

* small fix on external icon

* fix refresh search causing infinite rerender i.e. maximum call stack size exceeded

watch(route) → watch(() => [route.query.i, route.query.ai, route.path]) (line 102): The deep watch on the entire Vue Router route object was the most likely cause of the stack overflow. Vue Router's route object contains matched records with component definitions and other deeply nested structures. Deep-watching it triggers recursive traversal on every route change (including those from router.replace() inside refreshSearch()). Now it only watches the specific properties that updateInstanceContext() actually needs.

ref → shallowRef for serverHits and serverPings (line 189-190): The v3 search results can be deeply nested objects (minecraft_java_server.ping.data, content, etc.). Using shallowRef prevents Vue from creating deep reactive proxies on these objects, which is consistent with how results already uses shallowRef on line 295.

Re-entrance guard + try/catch on refreshSearch() (line 310): The watcher calls refreshSearch() without awaiting, so state changes during the async execution could trigger the watcher again, causing concurrent calls. The guard prevents overlapping calls, and the try/catch ensures loading.value = false is always reached (fixing the infinite loading).

* don't require auth token for logging server play

* fetch latest server player count from redis instead of search doc

* remove components. in search facet

* Category and search sort fixes

* add logging for refreshSearch in browse.vue

* fix: use windows.history.replace instead of router.replace due to vue production bug and remove logs

* fix: server refresh search reactivity

* fix: type errors

* conquer the type errors in Browse.vue

* update search input background

* fix tags location

* slight change to color

* feat: add linked to modpack project for regular modpack instances

* feat: installation tab updates

* fix: copy ip missing hover effect

* feat: implement category and countries negative filters

* fix servers tab label in profile page

* implement add server to instance

* feat: implement allow editing server instances

* update installation settings to handle vanilla server instance case

* hide servers tab when installing content to instance

* add sorting for user installed content to be top of list in content

* update categories filters from one group filter card to separate filters cards

* add active scale

* fix offline server showing online

* update language display

* update tooltip

* hide navtabs if theres only one tab

* fix: modpack content name truncate in project card

* feat: add server projects to moderation queue

* update redirect middleware no longer needs projectV3

* update comment

* fix: server tags labels

* feat: add the mf icons finally

* Revert "update redirect middleware no longer needs projectV3"

This reverts commit 1289cb52869185abe1481dfb6b0c00c0233bf59e.

* fix open in browser

* revert any handling for handling base linked modpack content for content tab

* update instance online players to be client ping

* fix showing modpack/loader version for server instance in installation settings

* server projects are not marked as modpacks

* skip license check for server projects

* feat: add the concept of linked worlds for server instances and keep in sync with server project

* fix: router.push doesn't add history state, use nagivateTo instead

* fix: get server modpack content wrong link

* update some categories to default collapse

* small fixes

* optional languages & bedrock

* move creator below tags

* sort linked worlds to be first

* add red orange and green ping variants

* bring back content tab

* add download button in required content in app

* fix: server info card loading

* fix: brief flash of normal project before server project stuff loads in

* misc fixes

* invalidate project v3

* fix unused imports

* Quick pass for moderation related changes (#5429)

* filter certain nags out from server projects.

* move add-links nag to links.ts

* first few server related nags

* moderation checklist groundwork

* Prevent undefined stage from appearing on servers.

* add projectV3 to shouldShow callback

* Filter buttons by server project type

* fix, revert private use msg, adjust server & link nags

* starting tags + servers msg

* fix no projectV3

* fix: router.push doesn't add history state, use nagivateTo instead

* Tags nag works with servers now

* support servers' v3 exclusive links

* reupload, and status messages + nag tweaks.

* fixes

* Update tags.vue warning for server projects.

* don't suggest adding a bedrock IP

* Tweak phrasing on servers alert msg

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* only show unique tags in project card

* add projectV3 to cache purge

* fix type: add projectV3 to cache purge

* update caching behaviour for installing

* max 3 plays per user

* accept date_modified and date_created for sorting

* add locking environment filter for server instance and update copy

* custom pack button only shows when needed (#5444)

* expose server pinging route to frontend

* feat: add server field validation with pinging on unfocus

* improve pinging logs

* try another pinging crate

* small fixes

* prefill published project id for updating published project

* fix running app bar for mac

* cargo sqlx prepare

* fix app login avatar

* pnpm prepr

* fix download menu for mac

* FIX CI

* fix lint errors

* cargo fmt

* fix toml

* fix more lint

* add server copy

* more lint

* fix any types

* also ping unlisted and private servers

* fix lint

* remove option for showTypeSelector

* fix cannot read user from undefined

* pnpm prepr

* update pinging to make it better

* update copy

* fix login cache issue

* add project select default icon

* fix: minecraft_java_server not redirecting

* pnpm prepr

* fix required content card in project page for custom modpack

* fix app project cards custom modpacks

* update pre-collapsed for app frontend

* don't send server projects to discord webhook

* add lock icon to linked world managed by server project

* pnpm prepr

* make automod msgs on server projects private

* fix pagination for server projects tab

* fix recent plays copy

* fix sync linked world with server project

* pnpm prepr

* add 0.11.0 changelog

* update date

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: aecsocket <aecsocket@tutanota.com>
Co-authored-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
This commit is contained in:
Truman Gao
2026-03-02 15:38:09 -08:00
committed by GitHub
parent 51066c476a
commit 51ceb9d851
318 changed files with 19891 additions and 4524 deletions

View File

@@ -0,0 +1,33 @@
use serde::{Deserialize, Serialize};
use validator::Validate;
use crate::models::{ids::OrganizationId, projects::ProjectStatus};
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct Project {
/// Human-readable friendly name of the project.
#[validate(
length(min = 3, max = 64),
custom(function = "crate::util::validate::validate_name")
)]
pub name: String,
/// Slug of the project, used in vanity URLs.
#[validate(
length(min = 3, max = 64),
regex(path = *crate::util::validate::RE_URL_SAFE)
)]
pub slug: String,
/// Short description of the project.
#[validate(length(min = 3, max = 255))]
pub summary: String,
/// A long description of the project, in markdown.
#[validate(length(max = 65536))]
pub description: String,
/// What status the user would like the project to be in after review.
pub requested_status: ProjectStatus,
/// What organization the project belongs to.
pub organization_id: Option<OrganizationId>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct Version {}

View File

@@ -0,0 +1,70 @@
//! Compatibility utilities for V3 API.
use crate::models::exp::ProjectSerial;
const MODPACK: &str = "modpack";
const MINECRAFT_JAVA_SERVER: &str = "minecraft_java_server";
/// Adjusts V3 project types based on a project's components.
///
/// The experimental API does not have a concept of project types; instead, a
/// project's "type" is implicit based on what components it has.
/// To reflect this in the V3 API, we manually add `project_types` values
/// for compatibility with stuff like searching.
pub fn correct_project_types(
components: &ProjectSerial,
project_types: &mut Vec<String>,
) {
if components.minecraft_server.is_some() {
// remove modpack type to reduce burden on frontend
project_types.retain(|t| t != MODPACK);
project_types.push(MINECRAFT_JAVA_SERVER.into());
}
}
#[cfg(test)]
mod tests {
use super::{MINECRAFT_JAVA_SERVER, MODPACK, correct_project_types};
use crate::models::exp::{ProjectSerial, minecraft::ServerProject};
fn server_components() -> ProjectSerial {
ProjectSerial {
minecraft_server: Some(ServerProject {
max_players: None,
country: None,
languages: vec![],
active_version: None,
}),
..ProjectSerial::default()
}
}
#[test]
fn adds_java_server_type_and_removes_modpack_for_server_projects() {
let components = server_components();
let mut project_types =
vec!["mod".to_string(), MODPACK.to_string(), "plugin".to_string()];
correct_project_types(&components, &mut project_types);
assert_eq!(
project_types,
vec![
"mod".to_string(),
"plugin".to_string(),
MINECRAFT_JAVA_SERVER.to_string()
]
);
}
#[test]
fn leaves_project_types_unchanged_without_server_component() {
let components = ProjectSerial::default();
let mut project_types = vec!["mod".to_string(), MODPACK.to_string()];
let expected = project_types.clone();
correct_project_types(&components, &mut project_types);
assert_eq!(project_types, expected);
}
}

View File

@@ -0,0 +1,335 @@
macro_rules! define {
() => {};
(
$(#[$meta:meta])*
$vis:vis struct $name:ident {
$(
#[base(
$($field_base_meta:meta),*
)]
#[edit(
$($field_edit_meta:meta),*
)]
#[create($create:ident)]
$(#[$field_meta:meta])*
$field_vis:vis $field:ident: $field_ty:ty
),* $(,)?
}
$($rest:tt)*
) => { paste::paste! {
$(#[$meta])*
$vis struct $name {
$(
$(#[$field_meta])*
$(#[$field_base_meta])*
$field_vis $field: $field_ty,
)*
}
$(#[$meta])*
$vis struct [< $name Edit >] {
$(
$(#[$field_meta])*
$(#[$field_edit_meta])*
$field_vis $field: Option<$field_ty>,
)*
}
impl $crate::models::exp::component::Component for $name {
type EntityId = $crate::models::ids::ProjectId;
type Query = $name;
type Edit = [< $name Edit >];
}
impl $crate::models::exp::component::ComponentQuery for $name {
type Component = $name;
type Context = $crate::models::exp::project::ProjectQueryContext;
type Requirements = $crate::models::exp::project::ProjectQueryRequirements;
fn collect_requirements(
_serial: &Self::Component,
_entity_id: <Self::Component as Component>::EntityId,
_requirements: &mut Self::Requirements,
) {}
fn populate(
serial: Self::Component,
_entity_id: <Self::Component as Component>::EntityId,
_context: &Self::Context,
) -> Result<Self> {
Ok(serial)
}
}
impl $crate::models::exp::component::ComponentEdit for [< $name Edit >] {
type Component = $name;
fn create(self) -> Result<Self::Component> {
Ok($name {
$(
$field: $crate::models::exp::component::unwrap_edit::$create(
self.$field,
stringify!($field),
)?,
)*
})
}
async fn apply_to(
self,
#[allow(unused_variables)]
component: &mut Self::Component,
) -> Result<()> {
$(
if let Some(f) = self.$field {
component.$field = f;
}
)*
Ok(())
}
}
$crate::models::exp::component::define!($($rest)*);
}};
}
pub mod unwrap_edit {
use eyre::{Result, eyre};
pub fn required<T>(field: Option<T>, field_name: &str) -> Result<T> {
field.ok_or_else(|| eyre!("missing field `{field_name}`"))
}
pub fn optional<T>(
field: Option<Option<T>>,
field_name: &str,
) -> Result<Option<T>> {
match field {
// present value
Some(Some(t)) => Ok(Some(t)),
// value is omitted from json -> no value
None => Ok(None),
// value is in json but is null -> empty
Some(None) => Err(eyre!("missing field `{field_name}`")),
}
}
pub fn default<T: Default>(
field: Option<T>,
_field_name: &str,
) -> Result<T> {
Ok(field.unwrap_or_default())
}
}
macro_rules! relations {
($vis:vis static $name:ident: $component_kind:ty = $expr:block) => {
$vis static $name: std::sync::LazyLock<Vec<$crate::models::exp::component::ComponentRelation<$component_kind>>> = std::sync::LazyLock::new(|| {
#[allow(unused_imports)]
use $crate::models::exp::component::{ComponentKindExt, ComponentKindArrayExt};
Vec::<$crate::models::exp::component::ComponentRelation<$component_kind>>::from($expr)
});
};
}
pub(crate) use define;
use eyre::Result;
pub(crate) use relations;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::{collections::HashSet, hash::Hash};
use thiserror::Error;
pub trait ComponentKind:
Clone + Send + Sync + PartialEq + Eq + Hash + 'static
{
}
/// Data attached to an entity (like a project or a version), comparable to a
/// component in the [ECS paradigm](https://en.wikipedia.org/wiki/Entity_component_system).
///
/// The struct that implements this trait is the *serial form* of the
/// component, as stored in the database. When it is queried or edited, the
/// schema may take a different form - see [`Component::Query`],
/// [`Component::Edit`].
pub trait Component: Sized + Serialize + DeserializeOwned {
/// Type of ID that entities which have this type of component use to
/// identify themselves.
///
/// - For project components, this is [`ProjectId`].
///
/// [`ProjectId`]: crate::models::ids::ProjectId
type EntityId: Clone + Copy + Eq + Hash + Send + Sync;
/// Schema of the data returned when querying a component of this type from
/// the backend.
///
/// See [`ComponentQuery`].
type Query: ComponentQuery<Component = Self>;
/// Schema of a modification that can be applied to an existing component of
/// this type.
///
/// See [`ComponentEdit`].
type Edit: ComponentEdit<Component = Self>;
}
/// Schema of the data returned when querying a component of type
/// [`Self::Component`] from the backend.
///
/// The [`Component`] stores persistent, serialized data; but when we
/// request a project, we also request its components, and we may want to
/// request extra data alongside the serialized form. For example, if our
/// component stores a project ID to another project, we may want to return
/// that project's name, icon, etc. alongside the ID. [`Component::Query`]
/// provides a way to populate this extra data.
pub trait ComponentQuery: Sized {
/// Type of serial component this [`ComponentQuery`] is queried from.
type Component: Component<Query = Self>;
/// Type of the whole set of information that a query requests from the
/// database.
///
/// - For project components, this is [`ProjectQueryRequirements`].
///
/// [`ProjectQueryRequirements`]: crate::models::exp::project::ProjectQueryRequirements
type Requirements;
/// Type of context provided during [`ComponentQuery::populate`].
///
/// - For project components, this is [`ProjectQueryContext`].
///
/// [`ProjectQueryContext`]: crate::models::exp::project::ProjectQueryContext
type Context;
/// What information does this query type require from the database to
/// populate itself (excluding the [`ComponentQuery::Component`])?
///
/// For example, if the [`ComponentQuery::Component`] has a projecet ID,
/// this will add the project ID to `requirements`. This will require the
/// fetcher to also fetch this project ID, which will be available in the
/// [`ComponentQuery::Context`] during [`ComponentQuery::populate`].
fn collect_requirements(
serial: &Self::Component,
entity_id: <Self::Component as Component>::EntityId,
requirements: &mut Self::Requirements,
);
/// Creates the final component with all queried data, using the serialized
/// form of the component ([`ComponentQuery::Component`]) and any additional
/// info requested in [`ComponentQuery::collect_requirements`]
///
/// # Errors
///
/// Errors if some required data in the `context` is missing, indicating a
/// logic bug.
fn populate(
serial: Self::Component,
entity_id: <Self::Component as Component>::EntityId,
context: &Self::Context,
) -> Result<Self>;
}
/// Schema of a modification to an existing component, or potentially creation
/// of a component.
///
/// The [`Component`] stores persistent, serialized data; but when we want to
/// edit only specific fields of an existing component, we have to be able to
/// exclude fields which are not edited by wrapping the field in an [`Option`].
/// This trait provides a schema for doing this.
pub trait ComponentEdit: Sized {
/// Type of serial component this [`ComponentQuery`] is a modification for.
type Component: Component<Edit = Self>;
/// Attempts to create a [`ComponentEdit::Component`] if this edit has all
/// of the appropriate fields set.
///
/// # Errors
///
/// Errors if a required field is missing.
fn create(self) -> Result<Self::Component>;
/// Applies this edit to an existing component.
///
/// Errors if an edit could not be applied.
// note: this is `async` because in the future this might issue db/sqlx queries
#[expect(async_fn_in_trait, reason = "internal trait")]
async fn apply_to(self, component: &mut Self::Component) -> Result<()>;
}
#[derive(Debug, Clone)]
pub enum ComponentRelation<K> {
/// If one of these components is present, then it can only be present with
/// other components from this set.
Only(HashSet<K>),
/// If component `0` is present, then `1` must also be present.
Requires(K, K),
}
pub trait ComponentKindExt<K> {
fn requires(self, other: K) -> ComponentRelation<K>;
}
impl<K> ComponentKindExt<K> for K {
fn requires(self, other: K) -> ComponentRelation<K> {
ComponentRelation::Requires(self, other)
}
}
pub trait ComponentKindArrayExt<K> {
fn only(self) -> ComponentRelation<K>;
}
impl<K: ComponentKind, const N: usize> ComponentKindArrayExt<K> for [K; N] {
fn only(self) -> ComponentRelation<K> {
ComponentRelation::Only(self.iter().cloned().collect())
}
}
#[derive(Debug, Clone, Error, Serialize, Deserialize)]
pub enum ComponentRelationError<K: ComponentKind> {
#[error("no components")]
NoComponents,
#[error("component `{target:?}` is missing")]
Missing { target: K },
#[error(
"only components {only:?} can be together, found extra components {extra:?}"
)]
Only { only: HashSet<K>, extra: HashSet<K> },
#[error("component `{target:?}` requires `{requires:?}`")]
Requires { target: K, requires: K },
}
pub fn kinds_valid<K: ComponentKind>(
kinds: &HashSet<K>,
relations: &[ComponentRelation<K>],
) -> Result<(), ComponentRelationError<K>> {
for relation in relations {
match relation {
ComponentRelation::Only(set) => {
if kinds.iter().any(|k| set.contains(k)) {
let extra: HashSet<_> =
kinds.difference(set).cloned().collect();
if !extra.is_empty() {
return Err(ComponentRelationError::Only {
only: set.clone(),
extra,
});
}
}
}
ComponentRelation::Requires(a, b) => {
if kinds.contains(a) && !kinds.contains(b) {
return Err(ComponentRelationError::Requires {
target: a.clone(),
requires: b.clone(),
});
}
}
}
}
Ok(())
}

View File

@@ -0,0 +1,395 @@
use std::time::Duration;
use chrono::{DateTime, Utc};
use eyre::Result;
use serde::{Deserialize, Serialize};
use tracing::warn;
use validator::Validate;
use crate::{
models::{
exp::{
ProjectComponentKind,
component::{self, Component, ComponentEdit, ComponentQuery},
project::{
ProjectComponent, ProjectQueryContext, ProjectQueryRequirements,
},
},
ids::{ProjectId, VersionId},
},
util::error::Context,
};
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum Language {
En,
Es,
Pt,
Fr,
De,
It,
Nl,
Ru,
Uk,
Pl,
Cs,
Sk,
Hu,
Ro,
Bg,
Hr,
Sr,
El,
Tr,
Ar,
He,
Hi,
Bn,
Ur,
Zh,
Ja,
Ko,
Th,
Vi,
Id,
Ms,
Tl,
Sv,
No,
Da,
Fi,
Lt,
Lv,
Et,
}
component::define! {
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct ModProject {}
/// Listing for a Minecraft server.
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct ServerProject {
#[base(serde(default))]
#[edit(serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
))]
#[create(optional)]
/// Maximum number of players allowed on the server.
pub max_players: Option<u32>,
#[base(serde(default))]
#[edit(serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
))]
#[create(optional)]
/// Country which this server is hosted in.
#[validate(length(min = 2, max = 2))]
pub country: Option<String>,
#[base(serde(default))]
#[edit(serde(default))]
#[create(default)]
/// Languages which the owners of this server prefer.
pub languages: Vec<Language>,
#[base(serde(default))]
#[edit(serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
))]
#[create(optional)]
/// Which version of the listing this server is currently using.
pub active_version: Option<VersionId>,
}
/// Version of a Minecraft Java server listing.
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct JavaServerVersion {}
/// Listing for a Minecraft Bedrock server.
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct BedrockServerProject {
#[base()]
#[edit(serde(default))]
#[create(required)]
/// Address (IP or domain name) of the Bedrock server, excluding port.
#[validate(length(max = 255))]
pub address: String,
#[base()]
#[edit(serde(default))]
#[create(required)]
/// Port which the server runs on.
pub port: u16,
}
}
impl ProjectComponent for ModProject {
fn kind() -> ProjectComponentKind {
ProjectComponentKind::MinecraftMod
}
}
impl ProjectComponent for ServerProject {
fn kind() -> ProjectComponentKind {
ProjectComponentKind::MinecraftServer
}
}
impl ProjectComponent for JavaServerProject {
fn kind() -> ProjectComponentKind {
ProjectComponentKind::MinecraftJavaServer
}
}
impl ProjectComponent for BedrockServerProject {
fn kind() -> ProjectComponentKind {
ProjectComponentKind::MinecraftBedrockServer
}
}
/// Listing for a Minecraft Java server.
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct JavaServerProject {
/// Address (IP or domain name) of the Java server, excluding port.
#[validate(length(max = 255))]
pub address: String,
/// Port which the server runs on.
pub port: u16,
/// What game content this server is using.
#[serde(default)]
pub content: ServerContent,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct JavaServerProjectEdit {
#[validate(length(max = 255))]
#[serde(default)]
pub address: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub content: Option<ServerContent>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct JavaServerProjectQuery {
pub address: String,
pub port: u16,
pub content: ServerContentQuery,
pub ping: Option<JavaServerPing>,
pub verified_plays_2w: Option<u64>,
pub verified_plays_4w: Option<u64>,
}
impl Component for JavaServerProject {
type EntityId = ProjectId;
type Query = JavaServerProjectQuery;
type Edit = JavaServerProjectEdit;
}
impl ComponentQuery for JavaServerProjectQuery {
type Component = JavaServerProject;
type Requirements = ProjectQueryRequirements;
type Context = ProjectQueryContext;
fn collect_requirements(
serial: &Self::Component,
project_id: ProjectId,
requirements: &mut ProjectQueryRequirements,
) {
match serial.content {
ServerContent::Vanilla { .. } => {}
ServerContent::Modpack { version_id } => {
requirements.partial_versions.insert(version_id);
}
}
requirements.minecraft_java_server_pings.insert(project_id);
requirements.minecraft_server_analytics.insert(project_id);
}
fn populate(
serial: Self::Component,
project_id: ProjectId,
context: &ProjectQueryContext,
) -> Result<Self> {
let analytics = context.minecraft_server_analytics.get(&project_id);
Ok(Self {
address: serial.address,
port: serial.port,
content: match serial.content {
ServerContent::Vanilla {
supported_game_versions,
recommended_game_version,
} => ServerContentQuery::Vanilla {
supported_game_versions,
recommended_game_version,
},
ServerContent::Modpack { version_id } => {
match context.partial_versions.get(&version_id) {
Some(version) => ServerContentQuery::Modpack {
version_id,
project_id: version.project_id,
project_name: version.project_name.clone(),
project_icon: version.project_icon.clone(),
},
None => {
// TODO: should be upgraded to an error,
// but it's too easy to fall into this illegal state right now
warn!("no modpack info for version {version_id:?}");
ServerContentQuery::Modpack {
version_id,
project_id: ProjectId(0),
project_name: String::new(),
project_icon: String::new(),
}
}
}
}
},
ping: context
.minecraft_java_server_pings
.get(&project_id)
.cloned(),
verified_plays_2w: analytics.map(|a| a.verified_plays_2w),
verified_plays_4w: analytics.map(|a| a.verified_plays_4w),
})
}
}
impl ComponentEdit for JavaServerProjectEdit {
type Component = JavaServerProject;
fn create(self) -> Result<Self::Component> {
Ok(JavaServerProject {
address: self.address.wrap_err("missing `address`")?,
port: self.port.wrap_err("missing `port`")?,
content: self.content.unwrap_or_default(),
})
}
async fn apply_to(self, component: &mut Self::Component) -> Result<()> {
if let Some(address) = self.address {
component.address = address;
}
if let Some(port) = self.port {
component.port = port;
}
if let Some(content) = self.content {
component.content = content;
}
Ok(())
}
}
/// What game content a [`JavaServerProject`] is using.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ServerContent {
/// Server runs modded content with a modpack found on the Modrinth platform.
Modpack {
/// Version ID of the modpack which the server runs.
///
/// This version may or may not belong to the server project, since
/// server projects may also be treated as modpacks.
version_id: VersionId,
},
/// Server is a vanilla Minecraft server.
Vanilla {
/// List of supported Minecraft Java client versions which can join this
/// server.
supported_game_versions: Vec<String>,
/// Recommended Minecraft Java client version to use to join this server.
recommended_game_version: Option<String>,
},
}
/// What game content a [`JavaServerProject`] is using.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ServerContentQuery {
/// Server runs modded content with a modpack found on the Modrinth platform.
Modpack {
version_id: VersionId,
project_id: ProjectId,
project_name: String,
project_icon: String,
},
/// Server is a vanilla Minecraft server.
Vanilla {
/// List of supported Minecraft Java client versions which can join this
/// server.
supported_game_versions: Vec<String>,
/// Recommended Minecraft Java client version to use to join this server.
recommended_game_version: Option<String>,
},
}
impl Default for ServerContent {
fn default() -> Self {
ServerContent::Vanilla {
supported_game_versions: Vec::new(),
recommended_game_version: None,
}
}
}
/// Recorded ping attempt that Labrinth made to a Minecraft Java server project.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct JavaServerPing {
/// When the ping was performed.
pub when: DateTime<Utc>,
/// Address of the server at the time of the ping.
pub address: String,
/// Port of the server at the time of the ping.
pub port: u16,
/// If the ping was successful, info on the ping response.
pub data: Option<JavaServerPingData>,
}
/// Ping response data for a Minecraft Java server.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct JavaServerPingData {
/// How long it took for the Labrinth worker to ping the server.
///
/// Note: this is explicitly *not* a client-side ping time, so this should
/// not be used to display to a client how much latency they have to a
/// specific server. This is purely for internal metrics.
pub latency: Duration,
/// Reported version name of the server.
pub version_name: String,
/// Reported version protocol number of the server.
pub version_protocol: u32,
/// Description/MOTD of the server as shown in the server list.
pub description: String,
/// Number of players online at the time.
pub players_online: u32,
/// Maximum number of players allowed on the server.
pub players_max: u32,
}
component::relations! {
pub(super) static PROJECT_COMPONENT_RELATIONS: ProjectComponentKind = {
use ProjectComponentKind::*;
[
[MinecraftMod].only(),
[MinecraftServer, MinecraftJavaServer, MinecraftBedrockServer].only(),
MinecraftJavaServer.requires(MinecraftServer),
MinecraftBedrockServer.requires(MinecraftServer),
]
}
}

View File

@@ -0,0 +1,29 @@
//! Highly experimental and unstable API endpoint models.
//!
//! These are used for testing new API patterns and exploring future endpoints,
//! which may or may not make it into an official release.
//!
//! # Projects and versions
//!
//! Projects and versions work in an ECS-like architecture, where each project
//! is an entity (project ID), and components can be attached to that project to
//! determine the project's type, like a Minecraft mod, data pack, etc. Project
//! components *may* store extra data (like a server listing which stores the
//! server address), but typically, the version will store this data in *version
//! components*.
pub mod base;
pub mod compat;
pub mod component;
pub mod minecraft;
pub mod project;
pub mod version;
pub use project::{
PROJECT_COMPONENT_RELATIONS, ProjectComponentKind, ProjectEdit,
ProjectQuery, ProjectSerial,
};
pub use version::{
VersionComponentKind, VersionCreate, VersionEdit, VersionQuery,
VersionSerial,
};

View File

@@ -0,0 +1,305 @@
use eyre::Result;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use validator::Validate;
use crate::{
database::{
models::{DBProjectId, DBVersionId},
redis::RedisPool,
},
models::{
exp::{
component::{
self, Component, ComponentEdit, ComponentKind, ComponentQuery,
},
minecraft,
},
ids::{ProjectId, VersionId},
},
queue::{
analytics::cache::{
MINECRAFT_SERVER_ANALYTICS, MinecraftServerAnalytics,
},
server_ping,
},
util::error::Context,
};
pub trait ProjectComponent: Component<EntityId = ProjectId> {
fn kind() -> ProjectComponentKind;
}
macro_rules! define_project_components {
(
$(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)?
) => {
// kinds
#[expect(dead_code, reason = "static check so $ty implements `ProjectComponent`")]
const _: () = {
fn assert_implements_component<T: ProjectComponent>() {}
fn assert_components_implement_trait() {
$(assert_implements_component::<$ty>();)*
}
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ProjectComponentKind {
$($variant_name,)*
}
impl ComponentKind for ProjectComponentKind {}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
pub struct ProjectSerial {
$(
#[validate(nested)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub $field_name: Option<$ty>,
)*
}
impl ProjectSerial {
#[must_use]
pub fn component_kinds(&self) -> HashSet<ProjectComponentKind> {
let mut kinds = HashSet::new();
$(
if self.$field_name.is_some() {
kinds.insert(ProjectComponentKind::$variant_name);
}
)*
kinds
}
pub fn collect_query_requirements(
&self,
project_id: ProjectId,
requirements: &mut ProjectQueryRequirements,
) {
$(
if let Some(component) = &self.$field_name {
<<$ty as Component>::Query as ComponentQuery>::collect_requirements(
component,
project_id,
requirements
);
}
)*
}
pub fn into_query(
self,
project_id: ProjectId,
context: &ProjectQueryContext,
) -> Result<ProjectQuery> {
Ok(ProjectQuery {
$(
$field_name: match self.$field_name {
Some(serial) => {
<$ty as Component>::Query::populate(
serial,
project_id,
context,
)
.map(Some)
.wrap_err(concat!("failed to populate `", stringify!($ty), "`"))?
}
None => None,
},
)*
})
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProjectQuery {
$(
#[serde(skip_serializing_if = "Option::is_none")]
pub $field_name: Option<Query<$ty>>,
)*
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct ProjectEdit {
$(
#[validate(nested)]
#[serde(skip_serializing_if = "Option::is_none", default)]
pub $field_name: Option<Edit<$ty>>,
)*
}
impl ProjectEdit {
#[must_use]
pub fn component_kinds(&self) -> HashSet<ProjectComponentKind> {
let mut kinds = HashSet::new();
$(
if self.$field_name.is_some() {
kinds.insert(ProjectComponentKind::$variant_name);
}
)*
kinds
}
pub fn create(self) -> Result<ProjectSerial> {
Ok(ProjectSerial {
$(
$field_name: self
.$field_name
.map(<<$ty as Component>::Edit as ComponentEdit>::create)
.transpose()?,
)*
})
}
}
};
}
// needed because the `utoipa::ToSchema` macro is broken
// when you have a `::` in the type path
type Edit<T> = <T as Component>::Edit;
type Query<T> = <T as Component>::Query;
define_project_components![
(minecraft_mod, MinecraftMod): minecraft::ModProject,
(minecraft_server, MinecraftServer): minecraft::ServerProject,
(minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerProject,
(minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServerProject,
];
component::relations! {
pub static PROJECT_COMPONENT_RELATIONS: ProjectComponentKind = {
minecraft::PROJECT_COMPONENT_RELATIONS.clone()
}
}
// query logic
#[derive(Default)]
pub struct ProjectQueryRequirements {
pub partial_versions: HashSet<VersionId>,
pub minecraft_java_server_pings: HashSet<ProjectId>,
pub minecraft_server_analytics: HashSet<ProjectId>,
}
pub struct ProjectQueryContext {
pub partial_versions: HashMap<VersionId, PartialVersion>,
pub minecraft_java_server_pings:
HashMap<ProjectId, minecraft::JavaServerPing>,
pub minecraft_server_analytics:
HashMap<ProjectId, MinecraftServerAnalytics>,
}
#[derive(Clone, Debug)]
pub struct PartialVersion {
pub project_id: ProjectId,
pub project_name: String,
pub project_icon: String,
}
pub async fn fetch_query_context(
projects: &[(ProjectId, &ProjectSerial)],
db: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
redis: &RedisPool,
) -> Result<ProjectQueryContext> {
let mut requirements = ProjectQueryRequirements::default();
for (project_id, project) in projects {
project.collect_query_requirements(*project_id, &mut requirements);
}
let ProjectQueryRequirements {
partial_versions,
minecraft_java_server_pings,
minecraft_server_analytics,
} = requirements;
let partial_versions = if partial_versions.is_empty() {
HashMap::new()
} else {
sqlx::query!(
r#"
SELECT
v.id AS "version_id: DBVersionId",
m.id AS "project_id: DBProjectId",
m.name AS "project_name!",
COALESCE(m.icon_url, '') AS "project_icon!"
FROM versions v
INNER JOIN mods m ON m.id = v.mod_id
WHERE v.id = ANY($1)
"#,
&partial_versions
.iter()
.map(|id| DBVersionId::from(*id).0)
.collect::<Vec<_>>(),
)
.fetch_all(db)
.await
.wrap_err("failed to fetch partial versions")?
.into_iter()
.map(|row| {
(
VersionId::from(row.version_id),
PartialVersion {
project_id: ProjectId::from(row.project_id),
project_name: row.project_name,
project_icon: row.project_icon,
},
)
})
.collect::<HashMap<_, _>>()
};
let mut redis = redis.connect().await?;
let minecraft_java_server_pings =
minecraft_java_server_pings.into_iter().collect::<Vec<_>>();
let minecraft_java_server_pings = if minecraft_java_server_pings.is_empty()
{
HashMap::new()
} else {
redis
.get_many_deserialized_from_json::<minecraft::JavaServerPing>(
server_ping::REDIS_NAMESPACE,
&minecraft_java_server_pings
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
)
.await?
.into_iter()
.enumerate()
.filter_map(|(idx, ping)| {
ping.map(|ping| (minecraft_java_server_pings[idx], ping))
})
.collect::<HashMap<_, _>>()
};
let minecraft_server_analytics =
minecraft_server_analytics.into_iter().collect::<Vec<_>>();
let minecraft_server_analytics = if minecraft_server_analytics.is_empty() {
HashMap::new()
} else {
redis
.get_many_deserialized_from_json::<MinecraftServerAnalytics>(
MINECRAFT_SERVER_ANALYTICS,
&minecraft_server_analytics
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
)
.await?
.into_iter()
.enumerate()
.filter_map(|(idx, data)| {
data.map(|data| (minecraft_server_analytics[idx], data))
})
.collect::<HashMap<_, _>>()
};
Ok(ProjectQueryContext {
partial_versions,
minecraft_java_server_pings,
minecraft_server_analytics,
})
}

View File

@@ -0,0 +1,98 @@
use crate::models::exp::{
base,
component::{Component, ComponentKind},
minecraft,
};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use validator::Validate;
macro_rules! define_version_components {
(
$(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)?
) => {
// kinds
#[expect(dead_code, reason = "static check so $ty implements `Component`")]
const _: () = {
fn assert_implements_component<T: Component>() {}
fn assert_components_implement_trait() {
$(assert_implements_component::<$ty>();)*
}
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum VersionComponentKind {
$($variant_name,)*
}
impl ComponentKind for VersionComponentKind {}
// structs
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct Version {
#[validate(nested)]
pub base: base::Version,
$(
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(nested)]
pub $field_name: Option<$ty>,
)*
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VersionSerial {
$(
pub $field_name: Option<$ty>,
)*
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct VersionCreate {
#[validate(nested)]
pub base: Option<base::Project>,
$(
#[validate(nested)]
pub $field_name: Option<$ty>,
)*
}
impl VersionCreate {
#[must_use]
pub fn component_kinds(&self) -> HashSet<VersionComponentKind> {
let mut kinds = HashSet::new();
$(if self.$field_name.is_some() {
kinds.insert(VersionComponentKind::$variant_name);
})*
kinds
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct VersionQuery {
$(
pub $field_name: Option<Query<$ty>>,
)*
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct VersionEdit {
$(
#[validate(nested)]
pub $field_name: Option<Edit<$ty>>,
)*
}
};
}
// needed because the `utoipa::ToSchema` macro is broken
// when you have a `::` in the type path
type Edit<T> = <T as Component>::Edit;
type Query<T> = <T as Component>::Query;
define_version_components![
(minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerVersion,
];

View File

@@ -1,4 +1,5 @@
pub mod error;
pub mod exp;
pub mod v2;
pub mod v3;

View File

@@ -2,6 +2,7 @@ use clickhouse::Row;
use serde::{Deserialize, Serialize};
use std::hash::Hash;
use std::net::Ipv6Addr;
use uuid::Uuid;
#[derive(Debug, Row, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
pub struct Download {
@@ -77,3 +78,12 @@ pub struct Playtime {
/// Parent modpack this playtime was recorded in
pub parent: u64,
}
#[derive(Row, Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash)]
pub struct MinecraftServerPlay {
pub recorded: i64,
pub user_id: u64,
pub project_id: u64,
#[serde(with = "clickhouse::serde::uuid")]
pub minecraft_uuid: Uuid,
}

View File

@@ -4,6 +4,7 @@ use std::mem;
use crate::database::models::loader_fields::VersionField;
use crate::database::models::project_item::{LinkUrl, ProjectQueryResult};
use crate::database::models::version_item::VersionQueryResult;
use crate::models::exp;
use crate::models::ids::{
FileId, OrganizationId, ProjectId, TeamId, ThreadId, VersionId,
};
@@ -95,6 +96,8 @@ pub struct Project {
/// The status of the manual review of the migration of side types of this project
pub side_types_migration_review_status: SideTypesMigrationReviewStatus,
#[serde(flatten)]
pub components: exp::ProjectQuery,
/// Aggregated loader-fields across its myriad of versions
#[serde(flatten)]
pub fields: HashMap<String, Vec<serde_json::Value>>,
@@ -212,6 +215,7 @@ impl From<ProjectQueryResult> for Project {
side_types_migration_review_status: m
.side_types_migration_review_status,
fields,
components: data.components,
}
}
}
@@ -642,7 +646,7 @@ impl SideTypesMigrationReviewStatus {
}
/// A specific version of a project
#[derive(Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct Version {
/// The ID of the version, encoded as a base62 string.
pub id: VersionId,
@@ -685,6 +689,8 @@ pub struct Version {
/// Ordering override, lower is returned first
pub ordering: Option<i32>,
#[serde(flatten)]
pub components: exp::VersionQuery,
// All other fields are loader-specific VersionFields
// These are flattened during serialization
#[serde(deserialize_with = "skip_nulls")]
@@ -761,6 +767,7 @@ impl From<VersionQueryResult> for Version {
.into_iter()
.map(|vf| (vf.field_name, vf.value.serialize_internal()))
.collect(),
components: data.components,
}
}
}
@@ -771,7 +778,9 @@ impl From<VersionQueryResult> for Version {
/// Draft - Version is not displayed on project, and not accessible by URL
/// Unlisted - Version is not displayed on project, and accessible by URL
/// Scheduled - Version is scheduled to be released in the future
#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)]
#[derive(
Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug, utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum VersionStatus {
Listed,
@@ -855,7 +864,7 @@ impl VersionStatus {
}
/// A single project file, with a url for the file and the file's hash
#[derive(Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct VersionFile {
/// The ID of the file. Every file has an ID once created, but it
/// is not known until it indeed has been created.
@@ -878,7 +887,9 @@ pub struct VersionFile {
/// A dendency which describes what versions are required, break support, or are optional to the
/// version's functionality
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[derive(
Serialize, Deserialize, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
)]
pub struct Dependency {
/// The specific version id that the dependency uses
pub version_id: Option<VersionId>,
@@ -890,7 +901,9 @@ pub struct Dependency {
pub dependency_type: DependencyType,
}
#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)]
#[derive(
Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug, utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum VersionType {
Release,
@@ -914,7 +927,9 @@ impl VersionType {
}
}
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq)]
#[derive(
Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum DependencyType {
Required,
@@ -951,7 +966,9 @@ impl DependencyType {
}
}
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq)]
#[derive(
Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum FileType {
RequiredResourcePack,
@@ -998,7 +1015,9 @@ impl FileType {
}
/// A project loader
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[derive(
Serialize, Deserialize, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
)]
#[serde(transparent)]
pub struct Loader(pub String);