This commit is contained in:
Mateusz Gruszczyński
2026-09-03 11:49:10 +02:00
parent 4c9f8ff403
commit d7b6d0478f
9 changed files with 7057 additions and 7 deletions
+3
View File
@@ -28,6 +28,8 @@ use crate::{
state::AppState,
};
mod openapi;
const INDEX_HTML: &str = include_str!("../web/index.html");
const NOT_FOUND_HTML: &str = include_str!("../web/404.html");
const APP_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/app.bundle.js"));
@@ -139,6 +141,7 @@ pub fn router(state: AppState) -> Router {
.route("/lang/:file", get(language_file))
.route("/presets/index.json", get(preset_index))
.route("/presets/:file", get(preset_file))
.merge(openapi::swagger_ui(&state.config.base_path))
.merge(protected)
.merge(home_assistant_api);
+103
View File
@@ -0,0 +1,103 @@
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])
.filter(true)
.try_it_out_enabled(true)
.display_request_duration(true)
.persist_authorization(true),
)
}
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"
);
}
}
}
}