use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize)]
pub struct OpenApiSpec {
pub openapi: String,
pub info: Info,
pub paths: BTreeMap<String, PathItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub components: Option<Components>,
}
impl OpenApiSpec {
pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
Self {
openapi: "3.0.3".to_string(),
info: Info {
title: title.into(),
version: version.into(),
description: None,
},
paths: BTreeMap::new(),
components: None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Info {
pub title: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct PathItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub get: Option<Operation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub post: Option<Operation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub put: Option<Operation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delete: Option<Operation>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Operation {
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "operationId", skip_serializing_if = "Option::is_none")]
pub operation_id: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub parameters: Vec<Parameter>,
#[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
pub request_body: Option<RequestBody>,
pub responses: BTreeMap<String, Response>,
}
impl Default for Operation {
fn default() -> Self {
let mut responses = BTreeMap::new();
responses.insert(
"200".to_string(),
Response {
description: "Success".to_string(),
content: None,
},
);
Self {
summary: None,
description: None,
operation_id: None,
parameters: Vec::new(),
request_body: None,
responses,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Parameter {
pub name: String,
#[serde(rename = "in")]
pub location: ParameterLocation,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema: Option<Schema>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ParameterLocation {
Path,
Query,
Header,
}
#[derive(Debug, Clone, Serialize)]
pub struct RequestBody {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub required: bool,
pub content: BTreeMap<String, MediaType>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Response {
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<BTreeMap<String, MediaType>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct MediaType {
pub schema: Schema,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum Schema {
Ref {
#[serde(rename = "$ref")]
reference: String,
},
Inline(serde_json::Value),
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct Components {
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub schemas: BTreeMap<String, serde_json::Value>,
}
fn error_response_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"required": ["error", "trace_id"],
"properties": {
"error": {
"type": "object",
"required": ["code", "message"],
"properties": {
"code": {"type": "string", "description": "Machine-readable error code"},
"message": {"type": "string", "description": "Human-readable error message"},
"details": {"type": "object", "description": "Optional additional details", "additionalProperties": true}
}
}
}
})
}
fn error_response_ref() -> Response {
let mut content = BTreeMap::new();
content.insert(
"application/json".to_string(),
MediaType {
schema: Schema::Ref {
reference: "#/components/schemas/ErrorResponse".to_string(),
},
},
);
Response {
description: "Error response".to_string(),
content: Some(content),
}
}
fn humanize_handler_name(name: &str) -> String {
let words: Vec<&str> = name.split('_').collect();
let mut result = String::new();
for (i, word) in words.iter().enumerate() {
if i > 0 {
result.push(' ');
}
if i == 0 {
let mut chars = word.chars();
if let Some(c) = chars.next() {
result.extend(c.to_uppercase());
result.push_str(chars.as_str());
}
} else {
result.push_str(word);
}
}
result
}
pub fn build_openapi_spec(
title: &str,
version: &str,
routes: &[crate::introspection::RouteInfo],
) -> OpenApiSpec {
let mut spec = OpenApiSpec::new(title, version);
let mut schemas = BTreeMap::new();
schemas.insert("ErrorResponse".to_string(), error_response_schema());
spec.components = Some(Components { schemas });
for route in routes {
if route.path.starts_with("/__rapina") {
continue;
}
let params: Vec<Parameter> = route
.path
.split('/')
.filter(|s| s.starts_with(':'))
.map(|s| Parameter {
name: s.trim_start_matches(':').to_string(),
location: ParameterLocation::Path,
description: None,
required: true,
schema: None,
})
.collect();
let openapi_path = route
.path
.split('/')
.map(|s| {
if s.starts_with(':') {
format!("{{{}}}", s.trim_start_matches(':'))
} else {
s.to_string()
}
})
.collect::<Vec<_>>()
.join("/");
let success_response = if let Some(schema) = &route.response_schema {
let mut content = BTreeMap::new();
content.insert(
"application/json".to_string(),
MediaType {
schema: Schema::Inline(schema.clone()),
},
);
Response {
description: "Success".to_string(),
content: Some(content),
}
} else {
Response {
description: "Success".to_string(),
content: None,
}
};
let summary = humanize_handler_name(&route.handler_name);
let mut operation = Operation {
summary: Some(summary),
operation_id: Some(route.handler_name.clone()),
parameters: params,
..Default::default()
};
operation
.responses
.insert("200".to_string(), success_response);
for error in &route.error_responses {
let status_key = error.status.to_string();
let error_desc = error.description.to_string();
operation.responses.entry(status_key).or_insert_with(|| {
let mut content = BTreeMap::new();
content.insert(
"application/json".to_string(),
MediaType {
schema: Schema::Ref {
reference: "#/components/schemas/ErrorResponse".to_string(),
},
},
);
Response {
description: error_desc,
content: Some(content),
}
});
}
operation
.responses
.insert("default".to_string(), error_response_ref());
let path_item = spec.paths.entry(openapi_path).or_default();
match route.method.to_uppercase().as_str() {
"GET" => path_item.get = Some(operation),
"POST" => path_item.post = Some(operation),
"PUT" => path_item.put = Some(operation),
"DELETE" => path_item.delete = Some(operation),
_ => {}
}
}
spec
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ErrorVariant;
use crate::introspection::RouteInfo;
#[test]
fn test_build_openapi_spec_basic() {
let routes = vec![RouteInfo::new(
"GET",
"/users",
"list_users",
None,
Vec::new(),
)];
let spec = build_openapi_spec("Test API", "1.0.0", &routes);
assert_eq!(spec.info.title, "Test API");
assert_eq!(spec.info.version, "1.0.0");
assert!(spec.paths.contains_key("/users"));
}
#[test]
fn test_build_openapi_spec_with_error_responses() {
let errors = vec![
ErrorVariant {
status: 404,
code: "NOT_FOUND",
description: "User not found",
},
ErrorVariant {
status: 409,
code: "CONFLICT",
description: "Email already taken",
},
];
let routes = vec![RouteInfo::new(
"GET",
"/users/:id",
"get_user",
None,
errors,
)];
let spec = build_openapi_spec("Test API", "1.0.0", &routes);
let path = spec.paths.get("/users/{id}").unwrap();
let get_op = path.get.as_ref().unwrap();
assert!(get_op.responses.contains_key("200"));
assert!(get_op.responses.contains_key("404"));
assert!(get_op.responses.contains_key("409"));
assert!(get_op.responses.contains_key("default"));
assert_eq!(
get_op.responses.get("404").unwrap().description,
"User not found"
);
assert_eq!(
get_op.responses.get("409").unwrap().description,
"Email already taken"
);
}
#[test]
fn test_build_openapi_spec_skips_internal_routes() {
let routes = vec![
RouteInfo::new("GET", "/__rapina/routes", "internal", None, Vec::new()),
RouteInfo::new("GET", "/users", "list_users", None, Vec::new()),
];
let spec = build_openapi_spec("Test API", "1.0.0", &routes);
assert!(!spec.paths.contains_key("/__rapina/routes"));
assert!(spec.paths.contains_key("/users"));
}
}