use super::*;
use utoipa::openapi::content::ContentBuilder;
use utoipa::openapi::path::{
HttpMethod, OperationBuilder, Parameter, ParameterBuilder, ParameterIn, Paths,
};
use utoipa::openapi::request_body::RequestBodyBuilder;
use utoipa::openapi::response::ResponseBuilder;
use utoipa::openapi::schema::Schema;
use utoipa::openapi::schema::{ArrayBuilder, ObjectBuilder, SchemaFormat, SchemaType, Type};
use utoipa::openapi::{Info, InfoBuilder, OpenApi, RefOr, Required};
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()
}
}
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: &[],
success_status: None,
body_params: &[],
response_type: None,
}
}
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,
success_status: None,
body_params: &[],
response_type: None,
}
}
#[allow(clippy::too_many_arguments)]
pub const fn with_path_params_and_status(
path: &'static str,
method: &'static str,
summary: &'static str,
description: &'static str,
version: &'static str,
tags: &'static [&'static str],
path_params: &'static [OpenApiPathParam],
success_status: Option<u16>,
) -> Self {
Self {
path,
method,
summary,
description,
version,
tags,
path_params,
success_status,
body_params: &[],
response_type: None,
}
}
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,
}
}
}
fn sanitize_operation_id(raw: &str) -> String {
raw.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'_'
}
})
.collect()
}
pub fn schema_for_type_name(rust_type: &str) -> OpenApiTypeInfo {
let (schema_type, schema_format) = match rust_type.trim() {
"u8" => ("integer", "uint8"),
"u16" => ("integer", "uint16"),
"u32" => ("integer", "uint32"),
"u64" => ("integer", "uint64"),
"u128" => ("integer", "uint128"),
"i8" => ("integer", "int8"),
"i16" => ("integer", "int16"),
"i32" => ("integer", "int32"),
"i64" => ("integer", "int64"),
"i128" => ("integer", "int128"),
"f32" => ("number", "float"),
"f64" => ("number", "double"),
"bool" => ("boolean", ""),
"String" | "&str" | "&'static str" => ("string", ""),
_ => ("object", ""),
};
OpenApiTypeInfo {
schema_type,
schema_format,
is_array: false,
}
}
fn schema_from_type(info: &OpenApiTypeInfo) -> Schema {
fn object_of(schema_type: SchemaType, format: Option<SchemaFormat>) -> Schema {
let mut builder = ObjectBuilder::new().schema_type(schema_type);
if let Some(fmt) = format {
builder = builder.format(Some(fmt));
}
Schema::Object(builder.build())
}
let format = |f: &str| {
if f.is_empty() {
None
} else {
Some(SchemaFormat::Custom(f.to_string()))
}
};
let element = || {
object_of(
match info.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::Object),
},
format(info.schema_format),
)
};
if info.is_array {
Schema::Array(ArrayBuilder::new().items(RefOr::T(element())).build())
} else {
element()
}
}
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(sanitize_operation_id(&format!(
"{}_{}",
route.version, route.path
))));
for param in route.path_params {
operation_builder = operation_builder.parameter(param.to_parameter());
}
if !route.body_params.is_empty() {
let mut request_body = RequestBodyBuilder::new();
let mut content = ContentBuilder::new();
if route.body_params.len() == 1 {
let param = &route.body_params[0];
let info = OpenApiTypeInfo {
schema_type: param.schema_type,
schema_format: param.schema_format,
is_array: param.schema_type == "array",
};
request_body = request_body.required(Some(if param.required {
Required::True
} else {
Required::False
}));
content = content.schema(Some(schema_from_type(&info)));
} else {
let mut props = ObjectBuilder::new().schema_type(Type::Object);
let mut required_names: Vec<String> = Vec::new();
for param in route.body_params {
let info = OpenApiTypeInfo {
schema_type: param.schema_type,
schema_format: param.schema_format,
is_array: param.schema_type == "array",
};
props = props.property(param.name, schema_from_type(&info));
if param.required {
required_names.push(param.name.to_string());
}
}
for name in &required_names {
props = props.required(name.as_str());
}
request_body = request_body.required(Some(Required::True));
content = content.schema(Some(Schema::Object(props.build())));
}
request_body = request_body.content("application/json", content.build());
operation_builder = operation_builder.request_body(Some(request_body.build()));
}
let status_code = route.success_status.unwrap_or(200);
let mut response = ResponseBuilder::new().description("Successful response");
if let Some(response_type) = route.response_type {
let mut content = ContentBuilder::new();
content = content.schema(Some(schema_from_type(&response_type)));
response = response.content("application/json", content.build());
}
let response = response.build();
operation_builder = operation_builder.response(status_code.to_string(), response);
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(all(test, feature = "openapi"))]
mod operation_id_tests {
use super::sanitize_operation_id;
#[test]
fn operation_id_keeps_spec_charset_unchanged() {
assert_eq!(sanitize_operation_id("v1_.plain-Id_9"), "v1_.plain-Id_9");
}
#[test]
fn operation_id_maps_path_and_placeholder_chars() {
assert_eq!(
sanitize_operation_id("v1_/api/v1/users/{id}"),
"v1__api_v1_users__id_"
);
assert!(!sanitize_operation_id("v1_/a/{b}").contains(['/', '{', '}']));
}
}