mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 18:14:49 +00:00
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:
co-authored by
aecsocket
parent
b26d048a63
commit
4a6fa9fc3d
+169
-24
@@ -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",
|
||||
))),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user