use utoipa::openapi::path::{
HttpMethod, OperationBuilder, Parameter, ParameterBuilder, ParameterIn, Paths,
};
use utoipa::openapi::schema::{ObjectBuilder, SchemaFormat, SchemaType, Type};
use utoipa::openapi::{Info, InfoBuilder, OpenApi, Required};
#[derive(Debug, Clone, Copy)]
pub struct OpenApiPathParam {
pub name: &'static str,
pub description: &'static str,
pub required: bool,
pub schema_type: &'static str,
pub schema_format: &'static str,
}
impl OpenApiPathParam {
pub const fn new(
name: &'static str,
description: &'static str,
required: bool,
schema_type: &'static str,
schema_format: &'static str,
) -> Self {
Self {
name,
description,
required,
schema_type,
schema_format,
}
}
pub fn to_parameter(&self) -> Parameter {
let schema_type = match self.schema_type {
"integer" => SchemaType::Type(Type::Integer),
"number" => SchemaType::Type(Type::Number),
"boolean" => SchemaType::Type(Type::Boolean),
"string" => SchemaType::Type(Type::String),
_ => SchemaType::Type(Type::String),
};
let format = if self.schema_format.is_empty() {
None
} else {
Some(SchemaFormat::Custom(self.schema_format.to_string()))
};
let schema = ObjectBuilder::new()
.schema_type(schema_type)
.format(format)
.build();
let desc = if self.description.is_empty() {
None
} else {
Some(self.description.to_string())
};
ParameterBuilder::new()
.name(self.name)
.parameter_in(ParameterIn::Path)
.required(Required::True)
.description(desc)
.schema(Some(schema))
.build()
}
}
#[derive(Debug, Clone, Copy)]
pub struct OpenApiRouteInfo {
pub path: &'static str,
pub method: &'static str,
pub summary: &'static str,
pub description: &'static str,
pub version: &'static str,
pub tags: &'static [&'static str],
pub path_params: &'static [OpenApiPathParam],
}
inventory::collect!(OpenApiRouteInfo);
impl OpenApiRouteInfo {
pub const fn new(
path: &'static str,
method: &'static str,
summary: &'static str,
description: &'static str,
version: &'static str,
tags: &'static [&'static str],
) -> Self {
Self {
path,
method,
summary,
description,
version,
tags,
path_params: &[],
}
}
pub const fn with_path_params(
path: &'static str,
method: &'static str,
summary: &'static str,
description: &'static str,
version: &'static str,
tags: &'static [&'static str],
path_params: &'static [OpenApiPathParam],
) -> Self {
Self {
path,
method,
summary,
description,
version,
tags,
path_params,
}
}
pub fn http_method(&self) -> HttpMethod {
match self.method.to_ascii_uppercase().as_str() {
"GET" => HttpMethod::Get,
"POST" => HttpMethod::Post,
"PUT" => HttpMethod::Put,
"DELETE" => HttpMethod::Delete,
"PATCH" => HttpMethod::Patch,
"HEAD" => HttpMethod::Head,
"OPTIONS" => HttpMethod::Options,
"TRACE" => HttpMethod::Trace,
_ => HttpMethod::Get,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct OpenApiBuilder {
title: String,
version: String,
description: Option<String>,
}
impl OpenApiBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn title<S: Into<String>>(mut self, title: S) -> Self {
self.title = title.into();
self
}
pub fn version<S: Into<String>>(mut self, version: S) -> Self {
self.version = version.into();
self
}
pub fn description<S: Into<String>>(mut self, description: S) -> Self {
self.description = Some(description.into());
self
}
pub fn build(&self) -> OpenApi {
let mut info_builder = InfoBuilder::new()
.title(self.title.clone())
.version(self.version.clone());
if let Some(desc) = &self.description {
info_builder = info_builder.description(Some(desc.clone()));
}
let info: Info = info_builder.build();
let mut paths = Paths::new();
for route in inventory::iter::<OpenApiRouteInfo> {
let mut operation_builder = OperationBuilder::new()
.summary(Some(route.summary.to_string()))
.description(Some(route.description.to_string()))
.tags(Some(
route
.tags
.iter()
.map(|t| (*t).to_string())
.collect::<Vec<_>>(),
))
.operation_id(Some(format!("{}_{}", route.version, route.path)));
for param in route.path_params {
operation_builder = operation_builder.parameter(param.to_parameter());
}
let operation = operation_builder.build();
paths.add_path_operation(route.path, vec![route.http_method()], operation);
}
OpenApi::new(info, paths)
}
}
pub fn generate_openapi_spec() -> OpenApi {
OpenApiBuilder::new()
.title("SDForge API")
.version(env!("CARGO_PKG_VERSION"))
.build()
}
#[cfg(test)]
inventory::submit!(OpenApiRouteInfo::new(
"/__openapi_test_marker__",
"GET",
"OpenAPI module test marker",
"Sentinel route registered by src/openapi/mod.rs tests to verify inventory collection.",
"test",
&["test"],
));
#[cfg(test)]
inventory::submit!(OpenApiRouteInfo::with_path_params(
"/__openapi_path_param_test__/{id}",
"GET",
"Path param test marker",
"Route with a u64 path param registered by src/openapi/mod.rs tests.",
"test",
&["test"],
&[OpenApiPathParam::new(
"id", "User ID", true, "integer", "uint64"
),],
));
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_new_yields_empty_fields() {
let builder = OpenApiBuilder::new();
assert!(builder.title.is_empty());
assert!(builder.version.is_empty());
assert!(builder.description.is_none());
}
#[test]
fn builder_chain_sets_all_fields() {
let builder = OpenApiBuilder::new()
.title("My API")
.version("9.9.9")
.description("hello");
assert_eq!(builder.title, "My API");
assert_eq!(builder.version, "9.9.9");
assert_eq!(builder.description.as_deref(), Some("hello"));
}
#[test]
fn builder_default_matches_new() {
let a = OpenApiBuilder::new();
let b = OpenApiBuilder::default();
assert_eq!(a.title, b.title);
assert_eq!(a.version, b.version);
assert_eq!(a.description, b.description);
}
#[test]
fn build_propagates_info_fields_without_description() {
let spec = OpenApiBuilder::new()
.title("Title X")
.version("0.0.1")
.build();
assert_eq!(spec.info.title, "Title X");
assert_eq!(spec.info.version, "0.0.1");
assert!(spec.info.description.is_none());
}
#[test]
fn build_propagates_description_when_set() {
let spec = OpenApiBuilder::new()
.title("T")
.version("1")
.description("desc")
.build();
assert_eq!(spec.info.description.as_deref(), Some("desc"));
}
#[test]
fn generate_openapi_spec_uses_crate_identity() {
let spec = generate_openapi_spec();
assert_eq!(spec.info.title, "SDForge API");
assert_eq!(spec.info.version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn generated_spec_contains_test_marker_route() {
let spec = generate_openapi_spec();
let paths_json = serde_json::to_value(&spec.paths).expect("paths serialize");
let paths_obj = paths_json
.as_object()
.expect("paths is a JSON object")
.keys()
.cloned()
.collect::<Vec<_>>();
assert!(
paths_obj.iter().any(|p| p == "/__openapi_test_marker__"),
"expected /__openapi_test_marker__ in paths, got {:?}",
paths_obj
);
}
#[test]
fn http_method_maps_all_canonical_variants() {
let cases = [
("GET", HttpMethod::Get),
("POST", HttpMethod::Post),
("PUT", HttpMethod::Put),
("DELETE", HttpMethod::Delete),
("PATCH", HttpMethod::Patch),
("HEAD", HttpMethod::Head),
("OPTIONS", HttpMethod::Options),
("TRACE", HttpMethod::Trace),
];
for (s, expected) in cases {
let info = OpenApiRouteInfo::new("/", s, "", "", "", &[]);
assert!(
info.http_method() == expected,
"method={} did not map to expected variant",
s
);
}
}
#[test]
fn http_method_is_case_insensitive() {
let info = OpenApiRouteInfo::new("/", "get", "", "", "", &[]);
assert!(info.http_method() == HttpMethod::Get);
}
#[test]
fn http_method_unknown_falls_back_to_get() {
let info = OpenApiRouteInfo::new("/", "CONNECT", "", "", "", &[]);
assert!(info.http_method() == HttpMethod::Get);
}
#[test]
fn route_info_new_populates_fields() {
let info = OpenApiRouteInfo::new(
"/users/{id}",
"GET",
"Fetch user",
"Fetch a user by id",
"v2",
&["users", "v2"],
);
assert_eq!(info.path, "/users/{id}");
assert_eq!(info.method, "GET");
assert_eq!(info.summary, "Fetch user");
assert_eq!(info.description, "Fetch a user by id");
assert_eq!(info.version, "v2");
assert_eq!(info.tags, &["users", "v2"]);
}
#[test]
fn route_info_is_copy() {
let info = OpenApiRouteInfo::new("/x", "GET", "s", "d", "v1", &[]);
let copied = info;
assert_eq!(info.path, copied.path);
assert_eq!(info.method, copied.method);
}
#[test]
fn build_succeeds_with_empty_inventory() {
let spec = OpenApiBuilder::new().title("T").version("0").build();
let json = serde_json::to_value(&spec.paths).expect("serialize");
assert!(json.is_object(), "paths must be a JSON object");
}
#[test]
fn builder_implements_clone_and_debug() {
let b = OpenApiBuilder::new().title("t").version("1");
let cloned = b.clone();
assert_eq!(b.title, cloned.title);
let debug = format!("{:?}", b);
assert!(debug.contains("OpenApiBuilder"));
}
#[test]
fn path_param_new_populates_fields() {
let p = OpenApiPathParam::new("id", "User ID", true, "integer", "uint64");
assert_eq!(p.name, "id");
assert_eq!(p.description, "User ID");
assert!(p.required);
assert_eq!(p.schema_type, "integer");
assert_eq!(p.schema_format, "uint64");
}
#[test]
fn path_param_to_parameter_builds_correct_parameter() {
let p = OpenApiPathParam::new("id", "User ID", true, "integer", "uint64");
let param = p.to_parameter();
assert_eq!(param.name, "id");
assert!(
matches!(param.parameter_in, utoipa::openapi::path::ParameterIn::Path),
"parameter_in must be Path"
);
assert!(
matches!(param.required, utoipa::openapi::Required::True),
"path parameter must be required"
);
let schema = param.schema.expect("schema must be present");
let json = serde_json::to_value(&schema).expect("schema serialize");
assert_eq!(json["type"], "integer", "schema type must be integer");
assert_eq!(json["format"], "uint64", "schema format must be uint64");
}
#[test]
fn path_param_to_parameter_omits_empty_format() {
let p = OpenApiPathParam::new("name", "", true, "string", "");
let param = p.to_parameter();
let schema = param.schema.expect("schema present");
let json = serde_json::to_value(&schema).expect("serialize");
assert_eq!(json["type"], "string");
assert!(
json.get("format").is_none() || json["format"].is_null(),
"format must be absent for empty schema_format"
);
}
#[test]
fn with_path_params_stores_params() {
const PARAMS: &[OpenApiPathParam] =
&[OpenApiPathParam::new("id", "", true, "integer", "uint64")];
const INFO: OpenApiRouteInfo =
OpenApiRouteInfo::with_path_params("/x/{id}", "GET", "s", "d", "v1", &[], PARAMS);
assert_eq!(INFO.path_params.len(), 1);
assert_eq!(INFO.path_params[0].name, "id");
assert_eq!(INFO.path_params[0].schema_type, "integer");
assert_eq!(INFO.path_params[0].schema_format, "uint64");
}
#[test]
fn generated_spec_contains_path_param_operation() {
let spec = generate_openapi_spec();
let paths_json = serde_json::to_value(&spec.paths).expect("paths serialize");
let paths_obj = paths_json
.as_object()
.expect("paths is a JSON object")
.clone();
let route_key = "/__openapi_path_param_test__/{id}";
let route = paths_obj
.get(route_key)
.unwrap_or_else(|| panic!("expected route {} in paths", route_key));
let get_op = route
.get("get")
.unwrap_or_else(|| panic!("expected GET operation on {}", route_key));
let params = get_op
.get("parameters")
.and_then(|p| p.as_array())
.unwrap_or_else(|| panic!("expected parameters array on {}", route_key));
assert_eq!(
params.len(),
1,
"expected exactly 1 parameter on {}",
route_key
);
let id_param = ¶ms[0];
assert_eq!(id_param["name"], "id", "parameter name must be id");
assert_eq!(id_param["in"], "path", "parameter in must be path");
assert_eq!(
id_param["required"], true,
"path parameter must be required"
);
assert_eq!(
id_param["schema"]["type"], "integer",
"schema type must be integer"
);
assert_eq!(
id_param["schema"]["format"], "uint64",
"schema format must be uint64"
);
}
}