use crate::{ApiDtoMetadata, ApiMetadata, ApiOutputShape, inventory};
use std::collections::HashMap;
use utoipa::openapi::{self, ComponentsBuilder, Schema};
use utoipa::openapi::path::{OperationBuilder, ParameterBuilder, ParameterIn};
use serde_json::Value;
pub fn build_openapi_basic(title: &str, version: &str, description: &str, tag: &str) -> openapi::OpenApi {
let mut openapi = openapi::OpenApiBuilder::new()
.info(
openapi::InfoBuilder::new()
.title(title)
.version(version)
.description(Some(description))
.build(),
)
.paths(openapi::Paths::new())
.build();
let mut schemas: HashMap<String, openapi::RefOr<Schema>> = inventory::iter::<ApiDtoMetadata>
.into_iter()
.map(|dto| (dto.schema_provider)())
.collect();
use utoipa::openapi::schema::{ObjectBuilder, Type};
let string_schema = openapi::RefOr::T(Schema::Object(ObjectBuilder::new().schema_type(Type::String).build()));
let integer_schema = openapi::RefOr::T(Schema::Object(ObjectBuilder::new().schema_type(Type::Integer).build()));
let number_schema = openapi::RefOr::T(Schema::Object(ObjectBuilder::new().schema_type(Type::Number).build()));
let boolean_schema = openapi::RefOr::T(Schema::Object(ObjectBuilder::new().schema_type(Type::Boolean).build()));
schemas.entry("String".into()).or_insert(string_schema.clone());
schemas.entry("&str".into()).or_insert(string_schema.clone());
schemas.entry("i32".into()).or_insert(integer_schema.clone());
schemas.entry("i64".into()).or_insert(integer_schema.clone());
schemas.entry("u32".into()).or_insert(integer_schema.clone());
schemas.entry("u64".into()).or_insert(integer_schema.clone());
schemas.entry("f32".into()).or_insert(number_schema.clone());
schemas.entry("f64".into()).or_insert(number_schema.clone());
schemas.entry("bool".into()).or_insert(boolean_schema.clone());
for metadata in inventory::iter::<ApiMetadata> {
let mut operation_builder = OperationBuilder::new()
.operation_id(Some(metadata.operation_id.to_string()))
.summary(Some(metadata.summary.to_string()))
.description(Some(metadata.description.to_string()))
.tag(tag);
for param in metadata.parameters {
let schema_ref = schemas
.get(param.type_name)
.cloned()
.unwrap_or_else(|| openapi::RefOr::T(Schema::default()));
match param.param_in {
crate::ParamIn::Path => {
let built_parameter = ParameterBuilder::new()
.name(param.name)
.required(utoipa::openapi::Required::True)
.description(Some(param.description))
.parameter_in(ParameterIn::Path)
.schema(Some(schema_ref))
.build();
operation_builder = operation_builder.parameter(built_parameter);
}
crate::ParamIn::Query => {
if let openapi::RefOr::T(Schema::Object(obj)) = &schema_ref {
for (prop_name, prop_schema) in obj.properties.iter() {
let is_required = obj.required.iter().any(|r| r == prop_name);
let built_parameter = ParameterBuilder::new()
.name(prop_name)
.required(if is_required { utoipa::openapi::Required::True } else { utoipa::openapi::Required::False })
.description(None::<&str>)
.parameter_in(ParameterIn::Query)
.schema(Some(prop_schema.clone()))
.build();
operation_builder = operation_builder.parameter(built_parameter);
}
if obj.properties.is_empty() {
let built_parameter = ParameterBuilder::new()
.name(param.name)
.required(if param.required { utoipa::openapi::Required::True } else { utoipa::openapi::Required::False })
.description(Some(param.description))
.parameter_in(ParameterIn::Query)
.schema(Some(schema_ref))
.build();
operation_builder = operation_builder.parameter(built_parameter);
}
} else {
let built_parameter = ParameterBuilder::new()
.name(param.name)
.required(if param.required { utoipa::openapi::Required::True } else { utoipa::openapi::Required::False })
.description(Some(param.description))
.parameter_in(ParameterIn::Query)
.schema(Some(schema_ref))
.build();
operation_builder = operation_builder.parameter(built_parameter);
}
}
}
}
if let Some(req_body_meta) = metadata.request_body {
let schema_ref = schemas
.get(req_body_meta.type_name)
.cloned()
.unwrap_or_else(|| openapi::RefOr::T(Schema::default()));
let request_body = utoipa::openapi::request_body::RequestBodyBuilder::new()
.description(Some(req_body_meta.description))
.required(Some(if req_body_meta.required { utoipa::openapi::Required::True } else { utoipa::openapi::Required::False }))
.content(
"application/json",
utoipa::openapi::ContentBuilder::new()
.schema(Some(match &schema_ref {
openapi::RefOr::T(_s) => schema_ref.clone(),
openapi::RefOr::Ref(r) => openapi::RefOr::Ref(r.clone()),
}))
.build(),
)
.build();
operation_builder = operation_builder.request_body(Some(request_body));
}
let mut responses_builder = utoipa::openapi::ResponsesBuilder::new();
for resp in metadata.responses {
let mut response_builder = utoipa::openapi::ResponseBuilder::new()
.description(resp.description);
if let Some(shape) = &metadata.output {
use utoipa::openapi::schema::{ObjectBuilder, ArrayBuilder, Type as UtoType};
match shape {
ApiOutputShape::Detail { type_name } => {
if let Some(schema_ref) = schemas.get(*type_name) {
response_builder = response_builder.content(
"application/json",
utoipa::openapi::ContentBuilder::new().schema(Some(schema_ref.clone())).build(),
);
}
}
ApiOutputShape::List { type_name } => {
let pagination_name = "PaginationInfo".to_string();
if !schemas.contains_key(&pagination_name) {
let pagination_schema = openapi::RefOr::T(Schema::Object(
ObjectBuilder::new()
.schema_type(UtoType::Object)
.property("page", openapi::RefOr::T(Schema::Object(ObjectBuilder::new().schema_type(UtoType::Integer).build())))
.property("pageSize", openapi::RefOr::T(Schema::Object(ObjectBuilder::new().schema_type(UtoType::Integer).build())))
.property("total", openapi::RefOr::T(Schema::Object(ObjectBuilder::new().schema_type(UtoType::Integer).build())))
.required("page")
.required("pageSize")
.required("total")
.build()
));
schemas.insert(pagination_name.clone(), pagination_schema);
}
let t_schema_ref = schemas.get(*type_name).cloned();
let pagination_ref = openapi::RefOr::Ref(openapi::Ref::from_schema_name("PaginationInfo"));
if let Some(t_ref) = t_schema_ref {
let array_schema = Schema::Array(
ArrayBuilder::new()
.items(t_ref)
.build()
);
let list_obj = Schema::Object(
ObjectBuilder::new()
.schema_type(UtoType::Object)
.property("data", openapi::RefOr::T(array_schema))
.property("pagination", pagination_ref)
.required("data")
.required("pagination")
.build()
);
response_builder = response_builder.content(
"application/json",
utoipa::openapi::ContentBuilder::new().schema(Some(openapi::RefOr::T(list_obj))).build(),
);
}
}
}
} else if let Some(type_name) = resp.type_name {
if let Some(schema_ref) = schemas.get(type_name) {
response_builder = response_builder.content(
"application/json",
utoipa::openapi::ContentBuilder::new().schema(Some(schema_ref.clone())).build()
);
}
}
responses_builder = responses_builder.response(resp.status_code.to_string(), response_builder.build());
}
operation_builder = operation_builder.responses(responses_builder.build());
let http_method = match metadata.method.to_lowercase().as_str() {
"get" => utoipa::openapi::path::HttpMethod::Get,
"post" => utoipa::openapi::path::HttpMethod::Post,
"put" => utoipa::openapi::path::HttpMethod::Put,
"delete" => utoipa::openapi::path::HttpMethod::Delete,
"patch" => utoipa::openapi::path::HttpMethod::Patch,
"options" => utoipa::openapi::path::HttpMethod::Options,
"head" => utoipa::openapi::path::HttpMethod::Head,
"trace" => utoipa::openapi::path::HttpMethod::Trace,
_ => continue,
};
operation_builder = operation_builder.security(SecurityRequirement::new(
"bearer_auth",
std::iter::empty::<String>(),
));
let operation = operation_builder.build();
let path_item = openapi
.paths
.paths
.entry(metadata.path.to_string())
.or_default();
match http_method {
utoipa::openapi::path::HttpMethod::Get => path_item.get = Some(operation),
utoipa::openapi::path::HttpMethod::Post => path_item.post = Some(operation),
utoipa::openapi::path::HttpMethod::Put => path_item.put = Some(operation),
utoipa::openapi::path::HttpMethod::Delete => path_item.delete = Some(operation),
utoipa::openapi::path::HttpMethod::Options => path_item.options = Some(operation),
utoipa::openapi::path::HttpMethod::Head => path_item.head = Some(operation),
utoipa::openapi::path::HttpMethod::Patch => path_item.patch = Some(operation),
utoipa::openapi::path::HttpMethod::Trace => path_item.trace = Some(operation),
}
}
use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme, SecurityRequirement};
let components = ComponentsBuilder::new()
.schemas_from_iter(schemas)
.security_scheme(
"bearer_auth",
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
)
.build();
openapi.components = Some(components);
validate_openapi_fail_fast(&openapi);
openapi
}
fn validate_openapi_fail_fast(openapi: &openapi::OpenApi) {
if let Some(components) = &openapi.components {
for key in components.schemas.keys() {
if key.is_empty() || key.chars().any(|c| matches!(c, '[' | ']' | '<' | '>' | ' ' | ',')) {
panic!("Illegal component name detected: {}", key);
}
}
}
let v: Value = serde_json::to_value(openapi).expect("serialize openapi");
let mut banned_hits: Vec<String> = Vec::new();
fn walk(value: &Value, acc: &mut Vec<String>) {
match value {
Value::Object(map) => {
if let Some(Value::String(r)) = map.get("$ref") {
if r.ends_with("#/components/schemas/Vec")
|| r.ends_with("#/components/schemas/PaginatedResponse")
|| r.ends_with("#/components/schemas/ApiResponseOf")
|| r.split('/').last().map(|n| n.starts_with("ListOf")).unwrap_or(false)
{
acc.push(r.clone());
}
}
for (_, v) in map.iter() {
walk(v, acc);
}
}
Value::Array(arr) => {
for v in arr { walk(v, acc); }
}
_ => {}
}
}
walk(&v, &mut banned_hits);
if !banned_hits.is_empty() {
panic!("Banned $ref targets detected: {:?}", banned_hits);
}
}