From 17062fa494919f67d219671acb69a4e1f37f63da Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 22 Oct 2025 12:57:15 +0100 Subject: [PATCH 1/4] Add utoipa Swagger UI support --- Cargo.lock | 112 ++++++++++++++++++ Cargo.toml | 3 + apps/labrinth/Cargo.toml | 3 + apps/labrinth/src/lib.rs | 7 ++ apps/labrinth/src/main.rs | 14 +++ apps/labrinth/src/routes/mod.rs | 8 +- apps/labrinth/src/routes/v3/analytics_get.rs | 74 +++++++----- .../old.rs} | 63 ++++++---- apps/labrinth/src/routes/v3/mod.rs | 12 +- apps/labrinth/tests/common/api_v2/mod.rs | 13 +- apps/labrinth/tests/common/api_v3/mod.rs | 13 +- packages/ariadne/Cargo.toml | 1 + packages/ariadne/src/ids.rs | 1 + 13 files changed, 256 insertions(+), 68 deletions(-) rename apps/labrinth/src/routes/v3/{analytics_get_old.rs => analytics_get/old.rs} (94%) diff --git a/Cargo.lock b/Cargo.lock index 85a8732952..41bea0586f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -478,6 +478,7 @@ dependencies = [ "serde_cbor", "serde_json", "thiserror 2.0.17", + "utoipa", "uuid 1.18.1", ] @@ -4678,6 +4679,9 @@ dependencies = [ "tracing-subscriber", "url", "urlencoding", + "utoipa", + "utoipa-actix-web", + "utoipa-swagger-ui", "uuid 1.18.1", "validator", "webp", @@ -7335,6 +7339,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb44e1917075637ee8c7bcb865cf8830e3a92b5b1189e44e3a0ab5a0d5be314b" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382499b49db77a7c19abd2a574f85ada7e9dbe125d5d1160fa5cad7c4cf71fc9" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.106", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21fcbee55c2458836bcdbfffb6ec9ba74bbc23ca7aa6816015a3dd2c4d8fc185" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -10309,6 +10347,66 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap 2.11.4", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-actix-web" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7eda9c23c05af0fb812f6a177514047331dac4851a2c8e9c4b895d6d826967f" +dependencies = [ + "actix-service", + "actix-web", + "utoipa", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.106", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "actix-web", + "base64 0.22.1", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "utoipa-swagger-ui-vendored", + "zip 3.0.0", +] + +[[package]] +name = "utoipa-swagger-ui-vendored" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" + [[package]] name = "uuid" version = "0.8.2" @@ -11687,6 +11785,20 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.11.4", + "memchr", + "zopfli", +] + [[package]] name = "zip" version = "4.6.1" diff --git a/Cargo.toml b/Cargo.toml index a661c69db6..eb2310501c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -192,6 +192,9 @@ tracing-subscriber = "0.3.20" typed-path = "0.12.0" url = "2.5.7" urlencoding = "2.1.3" +utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "decimal"] } +utoipa-actix-web = { version = "0.1.2" } +utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web", "vendored"] } uuid = "1.18.1" validator = "0.20.0" webp = { version = "0.3.1", default-features = false } diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 9f3075b976..055e2e5815 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -120,6 +120,9 @@ tracing-ecs = { workspace = true } tracing-subscriber = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } +utoipa = { workspace = true } +utoipa-actix-web = { workspace = true } +utoipa-swagger-ui = { workspace = true } uuid = { workspace = true, features = ["fast-rng", "serde", "v4"] } validator = { workspace = true, features = ["derive"] } webp = { workspace = true } diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index 2524c122f1..2dff703111 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -345,6 +345,13 @@ pub fn app_config( .default_service(web::get().wrap(default_cors()).to(routes::not_found)); } +pub fn utoipa_app_config( + cfg: &mut utoipa_actix_web::service_config::ServiceConfig, + _labrinth_config: LabrinthConfig, +) { + cfg.configure(routes::v3::utoipa_config); +} + // This is so that env vars not used immediately don't panic at runtime pub fn check_env_vars() -> bool { let mut failed = false; diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs index f2dbf027a5..643d606220 100644 --- a/apps/labrinth/src/main.rs +++ b/apps/labrinth/src/main.rs @@ -14,6 +14,7 @@ use labrinth::util::anrok; use labrinth::util::env::parse_var; use labrinth::util::gotenberg::GotenbergClient; use labrinth::util::ratelimit::rate_limit_middleware; +use labrinth::utoipa_app_config; use labrinth::{check_env_vars, clickhouse, database, file_hosting}; use std::ffi::CStr; use std::str::FromStr; @@ -25,6 +26,9 @@ use tracing_ecs::ECSLayerBuilder; use tracing_subscriber::EnvFilter; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; +use utoipa::OpenApi; +use utoipa_actix_web::AppExt; +use utoipa_swagger_ui::SwaggerUi; #[cfg(target_os = "linux")] #[global_allocator] @@ -293,6 +297,12 @@ async fn main() -> std::io::Result<()> { .wrap(from_fn(rate_limit_middleware)) .wrap(actix_web::middleware::Compress::default()) .wrap(sentry_actix::Sentry::new()) + .into_utoipa_app() + .configure(|cfg| utoipa_app_config(cfg, labrinth_config.clone())) + .openapi_service(|api| SwaggerUi::new("/docs/swagger-ui/{_:.*}") + .config(utoipa_swagger_ui::Config::default().try_it_out_enabled(true)) + .url("/docs/openapi.json", ApiDoc::openapi().merge_from(api))) + .into_app() .configure(|cfg| app_config(cfg, labrinth_config.clone())) }) .bind(dotenvy::var("BIND_ADDR").unwrap())? @@ -300,6 +310,10 @@ async fn main() -> std::io::Result<()> { .await } +#[derive(utoipa::OpenApi)] +#[openapi(info(title = "Labrinth"))] +struct ApiDoc; + fn log_error(err: &actix_web::Error) { if err.as_response_error().status_code().is_client_error() { tracing::debug!( diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 66a20a91f0..fad90bea03 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -77,12 +77,8 @@ pub fn root_config(cfg: &mut web::ServiceConfig) { }.boxed_local() }) ); - cfg.service( - web::scope("") - .wrap(default_cors()) - .service(index::index_get) - .service(Files::new("/", "assets/")), - ); + cfg.service(index::index_get); + cfg.service(Files::new("/", "assets/")); } #[derive(thiserror::Error, Debug)] diff --git a/apps/labrinth/src/routes/v3/analytics_get.rs b/apps/labrinth/src/routes/v3/analytics_get.rs index 7713b1da77..764c06159b 100644 --- a/apps/labrinth/src/routes/v3/analytics_get.rs +++ b/apps/labrinth/src/routes/v3/analytics_get.rs @@ -7,9 +7,11 @@ //! requests, you have to zip together M arrays of N elements //! - this makes it inconvenient to have separate endpoints +mod old; + use std::num::NonZeroU64; -use actix_web::{HttpRequest, web}; +use actix_web::{HttpRequest, post, web}; use chrono::{DateTime, TimeDelta, Utc}; use futures::StreamExt; use rust_decimal::Decimal; @@ -32,10 +34,9 @@ use crate::{ routes::ApiError, }; -// TODO: this service `analytics` is shadowed by `analytics_get_old`'s -// see the TODO in `analytics_get_old.rs` -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service(web::scope("analytics").route("", web::post().to(get))); +pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { + cfg.service(fetch_analytics); + cfg.configure(old::config); } // request @@ -43,7 +44,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { /// Requests analytics data, aggregating over all possible analytics sources /// like projects and affiliate codes, returning the data in a list of time /// slices. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct GetRequest { /// What time range to return statistics for. pub time_range: TimeRange, @@ -52,7 +53,7 @@ pub struct GetRequest { } /// Time range for fetching analytics. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct TimeRange { /// When to start including data. pub start: DateTime, @@ -68,20 +69,22 @@ pub struct TimeRange { /// Determines how many time slices between the start and end will be /// included, and how fine-grained those time slices will be. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum TimeRangeResolution { /// Use a set number of time slices, with the resolution being determined /// automatically. + #[schema(value_type = u64)] Slices(NonZeroU64), /// Each time slice will be a set number of minutes long, and the number of /// slices is determined automatically. + #[schema(value_type = u64)] Minutes(NonZeroU64), } /// What metrics the caller would like to receive from this analytics get /// request. -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct ReturnMetrics { /// How many times a project page has been viewed. pub project_views: Option>, @@ -90,11 +93,15 @@ pub struct ReturnMetrics { /// How long users have been playing a project. pub project_playtime: Option>, /// How much payout revenue a project has generated. - pub project_revenue: Option>, + pub project_revenue: Option>, } +/// Replacement for `()` because of a `utoipa` limitation. +#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Unit {} + /// See [`ReturnMetrics`]. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct Metrics { /// When collecting metrics, what fields do we want to group the results by? /// @@ -114,7 +121,9 @@ pub struct Metrics { } /// Fields for [`ReturnMetrics::project_views`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] #[serde(rename_all = "snake_case")] pub enum ProjectViewsField { /// Project ID. @@ -132,7 +141,9 @@ pub enum ProjectViewsField { } /// Fields for [`ReturnMetrics::project_downloads`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] #[serde(rename_all = "snake_case")] pub enum ProjectDownloadsField { /// Project ID. @@ -150,7 +161,9 @@ pub enum ProjectDownloadsField { } /// Fields for [`ReturnMetrics::project_playtime`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] #[serde(rename_all = "snake_case")] pub enum ProjectPlaytimeField { /// Project ID. @@ -177,15 +190,15 @@ pub const MAX_TIME_SLICES: usize = 1024; /// This is a list of N [`TimeSlice`]s, where each slice represents an equal /// time interval of metrics collection. The number of slices is determined /// by [`GetRequest::time_range`]. -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct GetResponse(pub Vec); +#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +pub struct FetchResponse(pub Vec); /// Single time interval of metrics collection. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct TimeSlice(pub Vec); /// Metrics collected in a single [`TimeSlice`]. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(untagged)] // the presence of `source_project`, `source_affiliate_code` determines the kind pub enum AnalyticsData { /// Project metrics. @@ -194,7 +207,7 @@ pub enum AnalyticsData { } /// Project metrics. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ProjectAnalytics { /// What project these metrics are for. source_project: ProjectId, @@ -213,7 +226,7 @@ impl ProjectAnalytics { /// Project metrics of a specific kind. /// /// If a field is not included in [`Metrics::bucket_by`], it will be [`None`]. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "snake_case", tag = "metric_kind")] pub enum ProjectMetrics { /// [`ReturnMetrics::project_views`]. @@ -227,7 +240,7 @@ pub enum ProjectMetrics { } /// [`ReturnMetrics::project_views`]. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct ProjectViews { /// [`ProjectViewsField::Domain`]. #[serde(skip_serializing_if = "Option::is_none")] @@ -246,7 +259,7 @@ pub struct ProjectViews { } /// [`ReturnMetrics::project_downloads`]. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct ProjectDownloads { /// [`ProjectDownloadsField::Domain`]. #[serde(skip_serializing_if = "Option::is_none")] @@ -265,7 +278,7 @@ pub struct ProjectDownloads { } /// [`ReturnMetrics::project_playtime`]. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct ProjectPlaytime { /// [`ProjectPlaytimeField::VersionId`]. #[serde(skip_serializing_if = "Option::is_none")] @@ -281,7 +294,7 @@ pub struct ProjectPlaytime { } /// [`ReturnMetrics::project_revenue`]. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct ProjectRevenue { /// Total revenue for this bucket. revenue: Decimal, @@ -414,14 +427,19 @@ mod query { }; } -pub async fn get( +/// Fetches analytics data for the authorized user's projects. +#[utoipa::path( + responses((status = OK, body = inline(FetchResponse))), +)] +#[post("/")] +pub async fn fetch_analytics( http_req: HttpRequest, req: web::Json, pool: web::Data, redis: web::Data, session_queue: web::Data, clickhouse: web::Data, -) -> Result, ApiError> { +) -> Result, ApiError> { let (scopes, user) = get_user_from_headers( &http_req, &**pool, @@ -655,7 +673,7 @@ pub async fn get( } } - Ok(web::Json(GetResponse(time_slices))) + Ok(web::Json(FetchResponse(time_slices))) } fn none_if_empty(s: String) -> Option { @@ -824,7 +842,7 @@ mod tests { let test_project_2 = ProjectId(456); let test_project_3 = ProjectId(789); - let src = GetResponse(vec![ + let src = FetchResponse(vec![ TimeSlice(vec![ AnalyticsData::Project(ProjectAnalytics { source_project: test_project_1, diff --git a/apps/labrinth/src/routes/v3/analytics_get_old.rs b/apps/labrinth/src/routes/v3/analytics_get/old.rs similarity index 94% rename from apps/labrinth/src/routes/v3/analytics_get_old.rs rename to apps/labrinth/src/routes/v3/analytics_get/old.rs index 0d50014be4..e12a1f948d 100644 --- a/apps/labrinth/src/routes/v3/analytics_get_old.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/old.rs @@ -13,7 +13,7 @@ use crate::{ }, queue::session::AuthQueue, }; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, get, web}; use ariadne::ids::base62_impl::to_base62; use chrono::{DateTime, Duration, Utc}; use eyre::eyre; @@ -24,28 +24,21 @@ use std::collections::HashMap; use std::convert::TryInto; use std::num::NonZeroU32; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope("analytics") - // TODO: since our service shadows analytics v2, we have to redirect here - .route("", web::post().to(super::analytics_get::get)) - .route("playtime", web::get().to(playtimes_get)) - .route("views", web::get().to(views_get)) - .route("downloads", web::get().to(downloads_get)) - .route("revenue", web::get().to(revenue_get)) - .route( - "countries/downloads", - web::get().to(countries_downloads_get), - ) - .route("countries/views", web::get().to(countries_views_get)), - ); +pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { + cfg.service(playtimes_get) + .service(views_get) + .service(downloads_get) + .service(revenue_get) + .service(countries_downloads_get) + .service(countries_views_get); } -/// The json data to be passed to fetch analytic data +/// The json data to be passed to fetch analytic data. +/// /// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. /// start_date and end_date are optional, and default to two weeks ago, and the maximum date respectively. /// resolution_minutes is optional. This refers to the window by which we are looking (every day, every minute, etc) and defaults to 1440 (1 day) -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, utoipa::ToSchema)] pub struct GetData { // only one of project_ids or version_ids should be used // if neither are provided, all projects the user has access to will be used @@ -54,10 +47,12 @@ pub struct GetData { pub start_date: Option>, // defaults to 2 weeks ago pub end_date: Option>, // defaults to now + #[schema(value_type = Option, minimum = 1)] pub resolution_minutes: Option, // defaults to 1 day. Ignored in routes that do not aggregate over a resolution (eg: /countries) } -/// Get playtime data for a set of projects or versions +/// Get playtime data for a set of projects or versions. +/// /// Data is returned as a hashmap of project/version ids to a hashmap of days to playtime data /// eg: /// { @@ -66,7 +61,7 @@ pub struct GetData { /// } ///} /// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)] pub struct FetchedPlaytime { pub time: u64, pub total_seconds: u64, @@ -74,6 +69,9 @@ pub struct FetchedPlaytime { pub game_version_seconds: HashMap, pub parent_seconds: HashMap, } + +#[utoipa::path] +#[get("/playtime")] pub async fn playtimes_get( req: HttpRequest, clickhouse: web::Data, @@ -134,7 +132,8 @@ pub async fn playtimes_get( Ok(HttpResponse::Ok().json(hm)) } -/// Get view data for a set of projects or versions +/// Get view data for a set of projects or versions. +/// /// Data is returned as a hashmap of project/version ids to a hashmap of days to views /// eg: /// { @@ -143,6 +142,8 @@ pub async fn playtimes_get( /// } ///} /// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. +#[utoipa::path] +#[get("/views")] pub async fn views_get( req: HttpRequest, clickhouse: web::Data, @@ -203,7 +204,8 @@ pub async fn views_get( Ok(HttpResponse::Ok().json(hm)) } -/// Get download data for a set of projects or versions +/// Get download data for a set of projects or versions. +/// /// Data is returned as a hashmap of project/version ids to a hashmap of days to downloads /// eg: /// { @@ -212,6 +214,8 @@ pub async fn views_get( /// } ///} /// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. +#[utoipa::path] +#[get("/downloads")] pub async fn downloads_get( req: HttpRequest, clickhouse: web::Data, @@ -273,7 +277,8 @@ pub async fn downloads_get( Ok(HttpResponse::Ok().json(hm)) } -/// Get payout data for a set of projects +/// Get payout data for a set of projects. +/// /// Data is returned as a hashmap of project ids to a hashmap of days to amount earned per day /// eg: /// { @@ -282,6 +287,8 @@ pub async fn downloads_get( /// } ///} /// ONLY project IDs can be used. Unauthorized projects will be filtered out. +#[utoipa::path] +#[get("/revenue")] pub async fn revenue_get( req: HttpRequest, data: web::Query, @@ -409,7 +416,8 @@ pub async fn revenue_get( Ok(HttpResponse::Ok().json(hm)) } -/// Get country data for a set of projects or versions +/// Get country data for a set of projects or versions. +/// /// Data is returned as a hashmap of project/version ids to a hashmap of coutnry to downloads. /// Unknown countries are labeled "". /// This is usable to see significant performing countries per project @@ -421,6 +429,8 @@ pub async fn revenue_get( ///} /// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. /// For this endpoint, provided dates are a range to aggregate over, not specific days to fetch +#[utoipa::path] +#[get("/countries/downloads")] pub async fn countries_downloads_get( req: HttpRequest, clickhouse: web::Data, @@ -482,7 +492,8 @@ pub async fn countries_downloads_get( Ok(HttpResponse::Ok().json(hm)) } -/// Get country data for a set of projects or versions +/// Get country data for a set of projects or versions. +/// /// Data is returned as a hashmap of project/version ids to a hashmap of coutnry to views. /// Unknown countries are labeled "". /// This is usable to see significant performing countries per project @@ -494,6 +505,8 @@ pub async fn countries_downloads_get( ///} /// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. /// For this endpoint, provided dates are a range to aggregate over, not specific days to fetch +#[utoipa::path] +#[get("/countries/views")] pub async fn countries_views_get( req: HttpRequest, clickhouse: web::Data, diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 7a989d81df..4e4c1aac8f 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -4,7 +4,6 @@ use actix_web::{HttpResponse, web}; use serde_json::json; pub mod analytics_get; -pub mod analytics_get_old; pub mod collections; pub mod friends; pub mod images; @@ -33,8 +32,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { web::scope("v3") .wrap(default_cors()) .configure(limits::config) - // .configure(analytics_get::config) // TODO: see `analytics_get` - .configure(analytics_get_old::config) .configure(collections::config) .configure(images::config) .configure(notifications::config) @@ -56,6 +53,15 @@ pub fn config(cfg: &mut web::ServiceConfig) { ); } +pub fn utoipa_config( + cfg: &mut utoipa_actix_web::service_config::ServiceConfig, +) { + cfg.service( + utoipa_actix_web::scope("/v3/analytics") + .configure(analytics_get::config), + ); +} + pub async fn hello_world() -> Result { Ok(HttpResponse::Ok().json(json!({ "hello": "world", diff --git a/apps/labrinth/tests/common/api_v2/mod.rs b/apps/labrinth/tests/common/api_v2/mod.rs index 20d0e6e3aa..e197c4b761 100644 --- a/apps/labrinth/tests/common/api_v2/mod.rs +++ b/apps/labrinth/tests/common/api_v2/mod.rs @@ -6,6 +6,7 @@ use actix_web::{App, dev::ServiceResponse, test}; use async_trait::async_trait; use labrinth::LabrinthConfig; use std::rc::Rc; +use utoipa_actix_web::AppExt; pub mod project; pub mod request_data; @@ -22,9 +23,15 @@ pub struct ApiV2 { #[async_trait(?Send)] impl ApiBuildable for ApiV2 { async fn build(labrinth_config: LabrinthConfig) -> Self { - let app = App::new().configure(|cfg| { - labrinth::app_config(cfg, labrinth_config.clone()) - }); + let app = App::new() + .into_utoipa_app() + .configure(|cfg| { + labrinth::utoipa_app_config(cfg, labrinth_config.clone()) + }) + .into_app() + .configure(|cfg| { + labrinth::app_config(cfg, labrinth_config.clone()) + }); let test_app: Rc = Rc::new(test::init_service(app).await); diff --git a/apps/labrinth/tests/common/api_v3/mod.rs b/apps/labrinth/tests/common/api_v3/mod.rs index e556d04027..1f28896de0 100644 --- a/apps/labrinth/tests/common/api_v3/mod.rs +++ b/apps/labrinth/tests/common/api_v3/mod.rs @@ -6,6 +6,7 @@ use actix_web::{App, dev::ServiceResponse, test}; use async_trait::async_trait; use labrinth::LabrinthConfig; use std::rc::Rc; +use utoipa_actix_web::AppExt; pub mod collections; pub mod limits; @@ -27,9 +28,15 @@ pub struct ApiV3 { #[async_trait(?Send)] impl ApiBuildable for ApiV3 { async fn build(labrinth_config: LabrinthConfig) -> Self { - let app = App::new().configure(|cfg| { - labrinth::app_config(cfg, labrinth_config.clone()) - }); + let app = App::new() + .into_utoipa_app() + .configure(|cfg| { + labrinth::utoipa_app_config(cfg, labrinth_config.clone()) + }) + .into_app() + .configure(|cfg| { + labrinth::app_config(cfg, labrinth_config.clone()) + }); let test_app: Rc = Rc::new(test::init_service(app).await); diff --git a/packages/ariadne/Cargo.toml b/packages/ariadne/Cargo.toml index 5b5dcd592b..1cab28f9e5 100644 --- a/packages/ariadne/Cargo.toml +++ b/packages/ariadne/Cargo.toml @@ -12,6 +12,7 @@ serde_bytes = { workspace = true } serde_cbor = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +utoipa = { workspace = true } uuid = { workspace = true, features = ["fast-rng", "serde", "v4"] } [lints] diff --git a/packages/ariadne/src/ids.rs b/packages/ariadne/src/ids.rs index 5b389c8f0d..6f91cecdd1 100644 --- a/packages/ariadne/src/ids.rs +++ b/packages/ariadne/src/ids.rs @@ -94,6 +94,7 @@ macro_rules! base62_id { serde::Deserialize, Debug, Hash, + utoipa::ToSchema, )] #[serde(from = "ariadne::ids::Base62Id")] #[serde(into = "ariadne::ids::Base62Id")] From 91b429e73280178aae0ef2aaaad80c447eb06b2e Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 22 Oct 2025 13:07:46 +0100 Subject: [PATCH 2/4] remove unused code --- .../src/routes/v3/analytics_get/old.rs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/apps/labrinth/src/routes/v3/analytics_get/old.rs b/apps/labrinth/src/routes/v3/analytics_get/old.rs index e12a1f948d..f10ec482b1 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/old.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/old.rs @@ -51,25 +51,6 @@ pub struct GetData { pub resolution_minutes: Option, // defaults to 1 day. Ignored in routes that do not aggregate over a resolution (eg: /countries) } -/// Get playtime data for a set of projects or versions. -/// -/// Data is returned as a hashmap of project/version ids to a hashmap of days to playtime data -/// eg: -/// { -/// "4N1tEhnO": { -/// "20230824": 23 -/// } -///} -/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. -#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)] -pub struct FetchedPlaytime { - pub time: u64, - pub total_seconds: u64, - pub loader_seconds: HashMap, - pub game_version_seconds: HashMap, - pub parent_seconds: HashMap, -} - #[utoipa::path] #[get("/playtime")] pub async fn playtimes_get( From ebe4a99fe96ff7f267aeb5988f661028ea910ebc Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 22 Oct 2025 13:14:04 +0100 Subject: [PATCH 3/4] remove unused code --- apps/labrinth/src/routes/v3/analytics_get/old.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/labrinth/src/routes/v3/analytics_get/old.rs b/apps/labrinth/src/routes/v3/analytics_get/old.rs index f10ec482b1..79c5330c00 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/old.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/old.rs @@ -7,10 +7,7 @@ use crate::models::teams::ProjectPermissions; use crate::{ auth::get_user_from_headers, database::models::user_item, - models::{ - ids::{ProjectId, VersionId}, - pats::Scopes, - }, + models::{ids::ProjectId, pats::Scopes}, queue::session::AuthQueue, }; use actix_web::{HttpRequest, HttpResponse, get, web}; From 9b4e93ea60fa327bdee3404d4fa758c9ebea5bcc Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 22 Oct 2025 13:14:53 +0100 Subject: [PATCH 4/4] consistency with trailing slash --- apps/labrinth/src/routes/v3/analytics_get.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/labrinth/src/routes/v3/analytics_get.rs b/apps/labrinth/src/routes/v3/analytics_get.rs index 764c06159b..924bcae5a0 100644 --- a/apps/labrinth/src/routes/v3/analytics_get.rs +++ b/apps/labrinth/src/routes/v3/analytics_get.rs @@ -431,7 +431,7 @@ mod query { #[utoipa::path( responses((status = OK, body = inline(FetchResponse))), )] -#[post("/")] +#[post("")] pub async fn fetch_analytics( http_req: HttpRequest, req: web::Json,