Files
gree-controller/src/api/openapi.rs
T
2026-09-18 10:27:39 +02:00

102 lines
3.4 KiB
Rust

use serde_json::{json, Value};
use utoipa_swagger_ui::{Config as SwaggerConfig, SwaggerUi};
const OPENAPI_JSON: &str = include_str!("../../docs/openapi.json");
pub(super) fn swagger_ui(base_path: &str) -> SwaggerUi {
let docs_url = format!("{}/api-docs/openapi.json", normalized_base_path(base_path));
SwaggerUi::new("/api-docs")
// Keep the route itself relative to the application router so Axum's
// outer base-path nesting prefixes it exactly once. Swagger UI may use
// the externally visible, base-path-aware URL when fetching the spec.
.external_url_unchecked("/api-docs/openapi.json", document(base_path))
.config(
SwaggerConfig::new([docs_url])
.doc_expansion("none")
.default_models_expand_depth(-1),
)
}
fn document(base_path: &str) -> Value {
let mut document: Value = serde_json::from_str(OPENAPI_JSON)
.expect("embedded OpenAPI document must contain valid JSON");
document["info"]["version"] = Value::String(env!("CARGO_PKG_VERSION").to_string());
document["servers"] = json!([{
"url": server_base_path(base_path),
"description": "This GREE Controller instance"
}]);
document
}
fn normalized_base_path(base_path: &str) -> String {
let base = base_path.trim().trim_end_matches('/');
if base.is_empty() || base == "/" {
String::new()
} else if base.starts_with('/') {
base.to_string()
} else {
format!("/{base}")
}
}
fn server_base_path(base_path: &str) -> String {
let base = normalized_base_path(base_path);
if base.is_empty() {
"/".into()
} else {
base
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn openapi_document_is_valid_json_and_version_is_runtime_version() {
let document = document("");
assert_eq!(document["openapi"], "3.1.0");
assert_eq!(document["info"]["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(document["servers"][0]["url"], "/");
}
#[test]
fn openapi_respects_configured_base_path() {
let document = document("/gree/");
assert_eq!(document["servers"][0]["url"], "/gree");
assert_eq!(normalized_base_path("/gree/"), "/gree");
}
#[test]
fn every_documented_operation_has_summary_description_and_responses() {
let document = document("");
let paths = document["paths"].as_object().expect("OpenAPI paths object");
for (path, item) in paths {
let methods = item.as_object().expect("OpenAPI path item");
for (method, operation) in methods {
if !matches!(method.as_str(), "get" | "post" | "put" | "patch" | "delete") {
continue;
}
assert!(
operation.get("summary").and_then(Value::as_str).is_some(),
"{method} {path} is missing summary"
);
assert!(
operation
.get("description")
.and_then(Value::as_str)
.is_some(),
"{method} {path} is missing description"
);
assert!(
operation
.get("responses")
.and_then(Value::as_object)
.is_some(),
"{method} {path} is missing responses"
);
}
}
}
}