rust-webx-openapi 0.7.0

rust-webx OpenAPI: specification generation and API docs UI
Documentation
//! OpenAPI 3.0.3 specification generator.
//!
//! Scans the inventory for all registered routes and builds
//! a rich OpenAPI JSON document with parameters, request bodies,
//! response schemas, names, and descriptions.

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;

/// Generate an OpenAPI 3.0.3 specification from registered routes.
///
/// Endpoint names and descriptions are resolved through `localizer` using the
/// host's configured default culture; OpenAPI has no multi-language operation
/// text, so one document is produced per hosted language.
///
/// Returns a `serde_json::Value` that can be serialized to JSON.
pub fn generate_openapi_spec(
    title: &str,
    version: &str,
    localizer: &dyn IStringLocalizer,
) -> JsonValue {
    let request_params = collect_request_params();
    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();
        // `#[category]` is the explicit grouping; fall back to the path-derived
        // tag so uncategorized endpoints keep their documentation grouping.
        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 has_body = false;
        let mut form_params: Vec<ParamMeta> = Vec::new();

        // An unmarked field (`source == ""`) is a body field on a method that
        // carries a body, and a query field everywhere else — the same rule the
        // runtime binder applies.
        let body_method = matches!(
            entry.method,
            HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch
        );

        for param in merge_params(entry.handler_type, entry.params, &request_params) {
            let source = match param.source {
                "" if body_method => "body",
                "" => "query",
                other => other,
            };

            match source {
                // An upload endpoint describes its fields as multipart parts;
                // the generic JSON body is not applicable.
                "form" => form_params.push(param),
                "body" => has_body = true,
                // Claims come from the principal, not from the request.
                "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 has_body {
            operation["requestBody"] = json!({
                "required": true,
                "content": {
                    "application/json": {
                        "schema": {
                            "type": "object",
                        }
                    }
                }
            });
        }

        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",
                }
            }
        }
    })
}

fn collect_request_params() -> HashMap<&'static str, &'static [ParamMeta]> {
    let mut map = HashMap::new();
    for entry in inventory::iter::<RequestParamEntry> {
        map.insert(entry.request_type, entry.params);
    }
    map
}

/// Merge route-level params (path from the route template) with the request
/// struct's own metadata.
///
/// The struct is the richer source: it knows real types and requiredness, while
/// the template only knows that a `{name}` segment exists. On a collision the
/// struct's type hint wins, so `{id}` documents as `integer` when the field is
/// an integer.
fn merge_params(
    request_type: &'static str,
    route_params: &'static [ParamMeta],
    request_params: &HashMap<&'static str, &'static [ParamMeta]>,
) -> Vec<ParamMeta> {
    let mut merged: Vec<ParamMeta> = route_params.to_vec();

    if let Some(extra) = request_params.get(request_type) {
        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
}

/// Build the `multipart/form-data` object schema for an upload endpoint.
///
/// `type_hint` of `binary` / `binary[]` (set by the derive for `FormFile`
/// fields) maps to OpenAPI's `string`/`format: binary` representation.
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,
    })
}

/// Build a responses object based on the response type name.
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" },
    })
}

/// Extract a tag name from a path for controller-style grouping.
///
/// Uses the last non-parameter segment as the tag, so routes like
/// `/api/users/{id}` and `/api/users` both map to tag `"Users"`.
///
/// e.g., "/api/users/{id}" →"Users", "/health" →"Health"
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)
}

/// Convert a handler type name to a camelCase operation ID.
/// "GetUserRequest" →"getUserRequest", "ListUsersRequest" →"listUsersRequest"
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(),
    }
}