feat: better api docs (#6586)

* feat: docs

* fix tombi

* chore: fix some of the routes rendering with missing /

* response schemas

* fix: restore labrinth docs routes

* Fix path parameter docs in routes

* remove utoipa-actix-web

* consistency

* improve version intros

* improve formatting, examples

* better hash examples, move openapi stuff to openapi.rs

* more utoipa param fixes

* request body docs

* chore: remove moderation route from v2, remove ingest & webhooks from v3 spec

* fixes

* chore: tweak sources titles

* fix

* fix test

* improve examples

* increase compiler spawned thread stack size

* remove unused tests & script

* test

* bro what

* fix

---------

Co-authored-by: aecsocket <43144841+aecsocket@users.noreply.github.com>
This commit is contained in:
François-Xavier Talbot
2026-07-04 14:19:21 +00:00
committed by GitHub
co-authored by aecsocket
parent b26d048a63
commit 4a6fa9fc3d
103 changed files with 4546 additions and 1387 deletions
+169 -24
View File
@@ -2,7 +2,7 @@
use actix_web::dev::Service;
use actix_web::middleware::from_fn;
use actix_web::{App, HttpServer, web};
use actix_web::{App, HttpResponse, HttpServer, web};
use actix_web_prom::PrometheusMetricsBuilder;
use clap::Parser;
@@ -15,17 +15,22 @@ use labrinth::search;
use labrinth::util::anrok;
use labrinth::util::gotenberg::GotenbergClient;
use labrinth::util::ratelimit::rate_limit_middleware;
use labrinth::utoipa_app_config;
use labrinth::{app_config, env};
use labrinth::{app_data_config, app_fallback_config, env};
use labrinth::{
app_routes_config_internal, app_routes_config_v2, app_routes_config_v3,
};
use labrinth::{clickhouse, database, file_hosting};
use scalar_api_reference::actix_web::config as scalar_config;
use serde_json::json;
use std::ffi::CStr;
use std::sync::Arc;
use tracing::{Instrument, info, info_span};
use tracing_actix_web::TracingLogger;
use utoipa::OpenApi;
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
use utoipa_actix_web::AppExt;
use utoipa_scalar::Servable;
use utoipa::PartialSchema;
use utoipa::openapi::Content;
use utoipa::openapi::response::Response;
use utoipa::openapi::schema::Components;
#[cfg(target_os = "linux")]
#[global_allocator]
@@ -223,7 +228,16 @@ async fn app() -> std::io::Result<()> {
info!("Starting Actix HTTP server!");
HttpServer::new(move || {
App::new()
let mut docs_v2 = labrinth::routes::v2::ApiDoc::openapi();
let mut docs_v3 = labrinth::routes::v3::ApiDoc::openapi();
let mut docs_internal = labrinth::routes::internal::ApiDoc::openapi();
#[cfg(target_os = "linux")]
docs_v3.merge(labrinth::routes::debug::ApiDoc::openapi());
document_error_responses(&mut docs_v2);
document_error_responses(&mut docs_v3);
document_error_responses(&mut docs_internal);
let app = App::new()
.wrap(TracingLogger::default())
.wrap_fn(|req, srv| {
// We capture the same fields as `tracing-actix-web`'s `RootSpanBuilder`.
@@ -260,31 +274,162 @@ async fn app() -> std::io::Result<()> {
// transactions out of HTTP requests. However, we have to use our
// own - See `sentry::SentryErrorReporting` for why.
.wrap(labrinth::util::sentry::SentryErrorReporting)
// Use `utoipa` for OpenAPI generation
.into_utoipa_app()
.configure(|cfg| utoipa_app_config(cfg, labrinth_config.clone()))
.openapi_service(|api| utoipa_scalar::Scalar::with_url("/docs", ApiDoc::openapi().merge_from(api)))
.into_app()
.configure(|cfg| app_config(cfg, labrinth_config.clone()))
.configure(|cfg| app_data_config(cfg, labrinth_config.clone()))
.configure(|cfg| app_routes_config_v2(cfg, labrinth_config.clone()))
.configure(|cfg| app_routes_config_v3(cfg, labrinth_config.clone()))
.configure(|cfg| {
app_routes_config_internal(cfg, labrinth_config.clone())
});
let scalar_configuration = json!({
"sources": [
{
"title": "API v2",
"slug": "v2",
"url": "/openapi/v2.json",
"default": true
},
{
"title": "API v3 (UNSTABLE)",
"slug": "v3",
"url": "/openapi/v3.json"
},
{
"title": "Internal API (HIGHLY UNSTABLE)",
"slug": "internal",
"url": "/openapi/internal.json"
}
],
"agent": {
"disabled": true
},
"mcp": {
"disabled": true
},
"telemetry": false,
"metaData": {
"title": "Modrinth API Documentation",
"description": "Reference documentation for the Modrinth API.",
"ogTitle": "Modrinth API Documentation",
"ogDescription": "Reference documentation for the Modrinth API."
},
"modelsSectionLabel": "Schemas",
"defaultOpenFirstTag": true,
"defaultOpenAllTags": false,
"expandAllResponses": false,
"expandAllSchemaProperties": false,
"expandAllModelSections": false,
"orderSchemaPropertiesBy": "preserve",
"orderRequiredPropertiesFirst": true,
"hideSearch": false,
"searchHotKey": "k",
"showOperationId": false,
"defaultHttpClient": {
"targetKey": "shell",
"clientKey": "curl"
},
"persistAuth": false,
"showDeveloperTools": "never",
});
app.service(openapi_json_service("/openapi/v2.json", docs_v2))
.service(openapi_json_service("/openapi/v3.json", docs_v3))
.service(openapi_json_service(
"/openapi/internal.json",
docs_internal,
))
.configure(scalar_config("/docs", &scalar_configuration))
.configure(app_fallback_config)
})
.bind(&ENV.BIND_ADDR)?
.run()
.await
}
#[derive(utoipa::OpenApi)]
#[openapi(info(title = "Labrinth"), modifiers(&SecurityAddon))]
struct ApiDoc;
fn openapi_json_service(
path: &'static str,
openapi: utoipa::openapi::OpenApi,
) -> actix_web::Resource {
web::resource(path).route(web::get().to(move || {
let openapi = openapi.clone();
async move { openapi_json(openapi) }
}))
}
struct SecurityAddon;
fn openapi_json(openapi: utoipa::openapi::OpenApi) -> HttpResponse {
match serde_json::to_string_pretty(&openapi) {
Ok(body) => HttpResponse::Ok()
.content_type("application/json; charset=utf-8")
.body(body),
Err(error) => {
tracing::error!(%error, "Failed to serialize OpenAPI schema");
HttpResponse::InternalServerError().finish()
}
}
}
impl utoipa::Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.as_mut().unwrap();
components.add_security_scheme(
"bearer_auth",
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new(
"authorization",
fn document_error_responses(openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Components::new);
components.schemas.insert(
"ApiError".to_string(),
labrinth::models::error::ApiError::schema(),
);
for path_item in openapi.paths.paths.values_mut() {
add_default_error_response(&mut path_item.get);
add_default_error_response(&mut path_item.put);
add_default_error_response(&mut path_item.post);
add_default_error_response(&mut path_item.delete);
add_default_error_response(&mut path_item.options);
add_default_error_response(&mut path_item.head);
add_default_error_response(&mut path_item.patch);
add_default_error_response(&mut path_item.trace);
}
}
fn add_default_error_response(
operation: &mut Option<utoipa::openapi::path::Operation>,
) {
if let Some(operation) = operation {
for (status, response) in &mut operation.responses.responses {
if !is_error_response_status(status) {
continue;
}
if let utoipa::openapi::RefOr::T(response) = response {
add_error_content(response);
}
}
operation
.responses
.responses
.entry("500".to_string())
.or_insert_with(|| error_response().into());
}
}
fn is_error_response_status(status: &str) -> bool {
matches!(status.as_bytes().first(), Some(b'4' | b'5'))
|| status == "default"
}
fn error_response() -> Response {
let mut response = Response::new("Error response");
add_error_content(&mut response);
response
}
fn add_error_content(response: &mut Response) {
if response.content.is_empty() {
response.content.insert(
"application/json".to_string(),
Content::new(Some(utoipa::openapi::Ref::from_schema_name(
"ApiError",
))),
);
}