use serde_json::{json, Value as JsonValue};
use std::collections::{BTreeSet, HashMap};
use webx_core::localization::IStringLocalizer;
use webx_core::route::scan::{ParamMeta, RequestParamEntry, RouteEntry};
use webx_core::routing::HttpMethod;
pub fn generate_openapi_spec(
title: &str,
version: &str,
localizer: &dyn IStringLocalizer,
) -> JsonValue {
let request_index = RequestParams::collect();
let mut paths = serde_json::Map::new();
let mut tags_set = BTreeSet::new();
for entry in inventory::iter::<RouteEntry> {
let entry = entry.clone();
let method = entry.method.as_str().to_lowercase();
let category = localizer.resolve(entry.category).value;
let tag = if category.is_empty() {
extract_tag(entry.path)
} else {
category
};
tags_set.insert(tag.clone());
let mut parameters = Vec::new();
let mut body_params: Vec<ParamMeta> = Vec::new();
let mut form_params: Vec<ParamMeta> = Vec::new();
let body_method = matches!(
entry.method,
HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch
);
let request_params = request_index.for_route(entry.req_type_id, entry.handler_type);
for param in merge_params(entry.params, request_params) {
let source = match param.source {
"" if body_method => "body",
"" => "query",
other => other,
};
match source {
"form" => form_params.push(param),
"body" => body_params.push(param),
"claim" => {}
_ => {
let required = param.required || source == "path";
parameters.push(json!({
"name": param.name,
"in": source,
"required": required,
"schema": { "type": param.type_hint },
}));
}
}
}
let operation_id = to_operation_id(entry.handler_type);
let summary = localizer.resolve(entry.name).value;
let mut operation = json!({
"operationId": operation_id,
"summary": summary,
"responses": build_responses(entry.rsp_type),
"tags": [tag],
});
let description = localizer.resolve(entry.description).value;
if !description.is_empty() {
operation["description"] = json!(description);
}
if !parameters.is_empty() {
operation["parameters"] = json!(parameters);
}
if !form_params.is_empty() {
operation["requestBody"] = json!({
"required": true,
"content": {
"multipart/form-data": {
"schema": form_schema(&form_params),
}
}
});
} else if body_method && !body_params.is_empty() {
operation["requestBody"] = json!({
"required": body_params.iter().any(|param| param.required),
"content": {
"application/json": {
"schema": body_schema(&body_params),
}
}
});
}
let path_entry = paths
.entry(entry.path.to_string())
.or_insert_with(|| json!({}));
if let Some(obj) = path_entry.as_object_mut() {
obj.insert(method, operation);
}
}
let tags: Vec<JsonValue> = tags_set.iter().map(|t| json!({"name": t})).collect();
json!({
"openapi": "3.0.3",
"info": {
"title": title,
"version": version,
},
"tags": tags,
"paths": paths,
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
}
}
}
})
}
struct RequestParams {
by_type: HashMap<std::any::TypeId, &'static [ParamMeta]>,
by_name: HashMap<&'static str, &'static [ParamMeta]>,
}
impl RequestParams {
fn collect() -> Self {
let mut by_type = HashMap::new();
let mut by_name = HashMap::new();
for entry in inventory::iter::<RequestParamEntry> {
by_name.insert(entry.request_type, entry.params);
if entry.req_type_id != std::any::TypeId::of::<()>() {
by_type.insert(entry.req_type_id, entry.params);
}
}
Self { by_type, by_name }
}
fn for_route(
&self,
req_type_id: std::any::TypeId,
request_type: &'static str,
) -> Option<&'static [ParamMeta]> {
self.by_type
.get(&req_type_id)
.copied()
.or_else(|| self.by_name.get(request_type).copied())
}
}
fn merge_params(
route_params: &'static [ParamMeta],
request_params: Option<&'static [ParamMeta]>,
) -> Vec<ParamMeta> {
let mut merged: Vec<ParamMeta> = route_params.to_vec();
if let Some(extra) = request_params {
for param in extra {
match merged
.iter_mut()
.find(|seen| seen.name == param.name && seen.source == param.source)
{
Some(existing) => {
if existing.type_hint == "string" && param.type_hint != "string" {
existing.type_hint = param.type_hint;
}
existing.required = existing.required || param.required;
}
None => merged.push(param.clone()),
}
}
}
merged
}
fn form_schema(params: &[ParamMeta]) -> JsonValue {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
for param in params {
let schema = match param.type_hint {
"binary" => json!({ "type": "string", "format": "binary" }),
"binary[]" => json!({
"type": "array",
"items": { "type": "string", "format": "binary" }
}),
other => json!({ "type": other }),
};
properties.insert(param.name.to_string(), schema);
if param.required {
required.push(json!(param.name));
}
}
json!({
"type": "object",
"properties": JsonValue::Object(properties),
"required": required,
})
}
fn body_schema(params: &[ParamMeta]) -> JsonValue {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
let mut opaque = false;
for param in params {
match param.json_key {
Some(key) => {
properties.insert(key.to_string(), body_property_schema(param.type_hint));
if param.required {
required.push(json!(key));
}
}
None => opaque = true,
}
}
let mut schema = json!({ "type": "object" });
if !properties.is_empty() {
schema["properties"] = JsonValue::Object(properties);
schema["required"] = json!(required);
}
if opaque {
schema["additionalProperties"] = json!(true);
}
schema
}
fn body_property_schema(type_hint: &str) -> JsonValue {
match type_hint {
"binary" => json!({ "type": "string", "format": "binary" }),
"array" => json!({ "type": "array" }),
other => json!({ "type": other }),
}
}
fn build_responses(rsp_type: &str) -> JsonValue {
let schema = if rsp_type == "()" || rsp_type == "unknown" {
json!({ "type": "object" })
} else if rsp_type == "String" {
json!({ "type": "string" })
} else if rsp_type.starts_with("Vec<") {
let inner = &rsp_type[4..rsp_type.len() - 1];
json!({
"type": "array",
"items": { "$ref": format!("#/components/schemas/{}", inner) }
})
} else {
json!({ "$ref": format!("#/components/schemas/{}", rsp_type) })
};
json!({
"200": {
"description": "Successful response",
"content": {
"application/json": { "schema": schema }
}
},
"400": { "description": "Bad request — validation or deserialization error" },
"404": { "description": "Resource not found" },
"500": { "description": "Internal server error" },
})
}
fn extract_tag(path: &str) -> String {
let tag = path
.trim_start_matches('/')
.split('/')
.rfind(|s| !s.starts_with('{') && !s.is_empty())
.unwrap_or("default");
titlecase(tag)
}
fn to_operation_id(handler_type: &str) -> String {
let name = handler_type.strip_suffix("Request").unwrap_or(handler_type);
let mut chars = name.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_ascii_lowercase().to_string() + chars.as_str(),
}
}
fn titlecase(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
}
}