use std::collections::{BTreeMap, HashMap};
use syn::{Fields, FnArg, Pat, PatType, ReturnType, Type};
use vespera_core::{
route::{MediaType, Operation, Parameter, ParameterLocation, RequestBody, Response},
schema::{Reference, Schema, SchemaRef, SchemaType},
};
pub fn extract_path_parameters(path: &str) -> Vec<String> {
let mut params = Vec::new();
let segments: Vec<&str> = path.split('/').collect();
for segment in segments {
if segment.starts_with('{') && segment.ends_with('}') {
let param = segment.trim_start_matches('{').trim_end_matches('}');
params.push(param.to_string());
} else if segment.starts_with(':') {
let param = segment.trim_start_matches(':');
params.push(param.to_string());
}
}
params
}
pub fn parse_function_parameter(
arg: &FnArg,
path_params: &[String],
known_schemas: &HashMap<String, String>,
) -> Option<Parameter> {
match arg {
FnArg::Receiver(_) => None,
FnArg::Typed(PatType { pat, ty, .. }) => {
let param_name = match pat.as_ref() {
Pat::Ident(ident) => ident.ident.to_string(),
Pat::TupleStruct(tuple_struct) => {
if tuple_struct.elems.len() == 1 {
match &tuple_struct.elems[0] {
Pat::Ident(ident) => ident.ident.to_string(),
_ => return None,
}
} else {
return None;
}
}
_ => return None,
};
if let Type::Path(type_path) = ty.as_ref() {
let path = &type_path.path;
if !path.segments.is_empty() {
let segment = &path.segments[0];
let ident_str = segment.ident.to_string();
match ident_str.as_str() {
"Path" => {
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments
&& let Some(syn::GenericArgument::Type(inner_ty)) =
args.args.first()
{
let name = if path_params.len() == 1 {
path_params[0].clone()
} else {
param_name
};
return Some(Parameter {
name,
r#in: ParameterLocation::Path,
description: None,
required: Some(true),
schema: Some(parse_type_to_schema_ref(inner_ty, known_schemas)),
example: None,
});
}
}
"Query" => {
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments
&& let Some(syn::GenericArgument::Type(inner_ty)) =
args.args.first()
{
return Some(Parameter {
name: param_name.clone(),
r#in: ParameterLocation::Query,
description: None,
required: Some(true),
schema: Some(parse_type_to_schema_ref(inner_ty, known_schemas)),
example: None,
});
}
}
"Header" => {
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments
&& let Some(syn::GenericArgument::Type(inner_ty)) =
args.args.first()
{
return Some(Parameter {
name: param_name.clone(),
r#in: ParameterLocation::Header,
description: None,
required: Some(true),
schema: Some(parse_type_to_schema_ref(inner_ty, known_schemas)),
example: None,
});
}
}
"Json" => {
return None;
}
_ => {}
}
}
}
if path_params.contains(¶m_name) {
return Some(Parameter {
name: param_name.clone(),
r#in: ParameterLocation::Path,
description: None,
required: Some(true),
schema: Some(parse_type_to_schema_ref(ty, known_schemas)),
example: None,
});
}
if is_primitive_type(ty.as_ref()) {
return Some(Parameter {
name: param_name.clone(),
r#in: ParameterLocation::Query,
description: None,
required: Some(true),
schema: Some(parse_type_to_schema_ref(ty, known_schemas)),
example: None,
});
}
None
}
}
}
fn is_primitive_type(ty: &Type) -> bool {
match ty {
Type::Path(type_path) => {
let path = &type_path.path;
if path.segments.len() == 1 {
let ident = path.segments[0].ident.to_string();
matches!(
ident.as_str(),
"i8" | "i16"
| "i32"
| "i64"
| "u8"
| "u16"
| "u32"
| "u64"
| "f32"
| "f64"
| "bool"
| "String"
| "str"
)
} else {
false
}
}
_ => false,
}
}
fn extract_rename_all(attrs: &[syn::Attribute]) -> Option<String> {
for attr in attrs {
if attr.path().is_ident("serde") {
let tokens = attr.meta.require_list().ok()?;
let token_str = tokens.tokens.to_string();
if let Some(start) = token_str.find("rename_all") {
let remaining = &token_str[start + "rename_all".len()..];
if let Some(equals_pos) = remaining.find('=') {
let value_part = &remaining[equals_pos + 1..].trim();
if value_part.starts_with('"') && value_part.ends_with('"') {
let value = &value_part[1..value_part.len() - 1];
return Some(value.to_string());
}
}
}
}
}
None
}
fn extract_field_rename(attrs: &[syn::Attribute]) -> Option<String> {
for attr in attrs {
if attr.path().is_ident("serde") {
if let syn::Meta::List(meta_list) = &attr.meta {
let tokens = meta_list.tokens.to_string();
if let Some(start) = tokens.find("rename") {
let remaining = &tokens[start + "rename".len()..];
if let Some(equals_pos) = remaining.find('=') {
let value_part = &remaining[equals_pos + 1..].trim();
if value_part.starts_with('"') && value_part.ends_with('"') {
let value = &value_part[1..value_part.len() - 1];
return Some(value.to_string());
}
}
}
}
}
}
None
}
fn rename_field(field_name: &str, rename_all: Option<&str>) -> String {
match rename_all {
Some("camelCase") => {
let mut result = String::new();
let mut capitalize_next = false;
for ch in field_name.chars() {
if ch == '_' {
capitalize_next = true;
} else if capitalize_next {
result.push(ch.to_uppercase().next().unwrap_or(ch));
capitalize_next = false;
} else {
result.push(ch);
}
}
result
}
Some("snake_case") => {
let mut result = String::new();
for (i, ch) in field_name.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
result.push('_');
}
result.push(ch.to_lowercase().next().unwrap_or(ch));
}
result
}
Some("kebab-case") => {
field_name.replace('_', "-")
}
Some("PascalCase") => {
let mut result = String::new();
let mut capitalize_next = true;
for ch in field_name.chars() {
if ch == '_' {
capitalize_next = true;
} else if capitalize_next {
result.push(ch.to_uppercase().next().unwrap_or(ch));
capitalize_next = false;
} else {
result.push(ch);
}
}
result
}
Some("lowercase") => {
field_name.to_lowercase()
}
Some("UPPERCASE") => {
field_name.to_uppercase()
}
Some("SCREAMING_SNAKE_CASE") => {
if field_name.chars().all(|c| c.is_uppercase() || c == '_') && field_name.contains('_')
{
return field_name.to_string();
}
let mut snake_case = String::new();
for (i, ch) in field_name.chars().enumerate() {
if ch.is_uppercase() && i > 0 && !snake_case.ends_with('_') {
snake_case.push('_');
}
if ch != '_' && ch != '-' {
snake_case.push(ch.to_lowercase().next().unwrap_or(ch));
} else if ch == '_' {
snake_case.push('_');
}
}
snake_case.to_uppercase()
}
Some("SCREAMING-KEBAB-CASE") => {
let mut kebab_case = String::new();
for (i, ch) in field_name.chars().enumerate() {
if ch.is_uppercase()
&& i > 0
&& !kebab_case.ends_with('-')
&& !kebab_case.ends_with('_')
{
kebab_case.push('-');
}
if ch == '_' {
kebab_case.push('-');
} else if ch != '-' {
kebab_case.push(ch.to_lowercase().next().unwrap_or(ch));
} else {
kebab_case.push('-');
}
}
kebab_case.to_uppercase()
}
_ => field_name.to_string(),
}
}
pub fn parse_enum_to_schema(
enum_item: &syn::ItemEnum,
known_schemas: &HashMap<String, String>,
) -> Schema {
let rename_all = extract_rename_all(&enum_item.attrs);
let all_unit = enum_item
.variants
.iter()
.all(|v| matches!(v.fields, syn::Fields::Unit));
if all_unit {
let mut enum_values = Vec::new();
for variant in &enum_item.variants {
let variant_name = variant.ident.to_string();
let enum_value = if let Some(renamed) = extract_field_rename(&variant.attrs) {
renamed
} else {
rename_field(&variant_name, rename_all.as_deref())
};
enum_values.push(serde_json::Value::String(enum_value));
}
Schema {
schema_type: Some(SchemaType::String),
r#enum: if enum_values.is_empty() {
None
} else {
Some(enum_values)
},
..Schema::string()
}
} else {
let mut one_of_schemas = Vec::new();
for variant in &enum_item.variants {
let variant_name = variant.ident.to_string();
let variant_key = if let Some(renamed) = extract_field_rename(&variant.attrs) {
renamed
} else {
rename_field(&variant_name, rename_all.as_deref())
};
let variant_schema = match &variant.fields {
syn::Fields::Unit => {
Schema {
schema_type: Some(SchemaType::String),
r#enum: Some(vec![serde_json::Value::String(variant_key)]),
..Schema::string()
}
}
syn::Fields::Unnamed(fields_unnamed) => {
if fields_unnamed.unnamed.len() == 1 {
let inner_type = &fields_unnamed.unnamed[0].ty;
let inner_schema = parse_type_to_schema_ref(inner_type, known_schemas);
let mut properties = BTreeMap::new();
properties.insert(variant_key.clone(), inner_schema);
Schema {
schema_type: Some(SchemaType::Object),
properties: Some(properties),
required: Some(vec![variant_key]),
..Schema::object()
}
} else {
let mut tuple_item_schemas = Vec::new();
for field in &fields_unnamed.unnamed {
let field_schema = parse_type_to_schema_ref(&field.ty, known_schemas);
tuple_item_schemas.push(field_schema);
}
let tuple_len = tuple_item_schemas.len();
let array_schema = Schema {
schema_type: Some(SchemaType::Array),
prefix_items: Some(tuple_item_schemas),
min_items: Some(tuple_len),
max_items: Some(tuple_len),
items: None, ..Schema::new(SchemaType::Array)
};
let mut properties = BTreeMap::new();
properties.insert(
variant_key.clone(),
SchemaRef::Inline(Box::new(array_schema)),
);
Schema {
schema_type: Some(SchemaType::Object),
properties: Some(properties),
required: Some(vec![variant_key]),
..Schema::object()
}
}
}
syn::Fields::Named(fields_named) => {
let mut variant_properties = BTreeMap::new();
let mut variant_required = Vec::new();
let variant_rename_all = extract_rename_all(&variant.attrs);
for field in &fields_named.named {
let rust_field_name = field
.ident
.as_ref()
.map(|i| i.to_string())
.unwrap_or_else(|| "unknown".to_string());
let field_name = if let Some(renamed) = extract_field_rename(&field.attrs) {
renamed
} else {
rename_field(
&rust_field_name,
variant_rename_all.as_deref().or(rename_all.as_deref()),
)
};
let field_type = &field.ty;
let schema_ref = parse_type_to_schema_ref(field_type, known_schemas);
variant_properties.insert(field_name.clone(), schema_ref);
let is_optional = matches!(
field_type,
Type::Path(type_path)
if type_path
.path
.segments
.first()
.map(|s| s.ident == "Option")
.unwrap_or(false)
);
if !is_optional {
variant_required.push(field_name);
}
}
let inner_struct_schema = Schema {
schema_type: Some(SchemaType::Object),
properties: if variant_properties.is_empty() {
None
} else {
Some(variant_properties)
},
required: if variant_required.is_empty() {
None
} else {
Some(variant_required)
},
..Schema::object()
};
let mut properties = BTreeMap::new();
properties.insert(
variant_key.clone(),
SchemaRef::Inline(Box::new(inner_struct_schema)),
);
Schema {
schema_type: Some(SchemaType::Object),
properties: Some(properties),
required: Some(vec![variant_key]),
..Schema::object()
}
}
};
one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema)));
}
Schema {
schema_type: None, one_of: if one_of_schemas.is_empty() {
None
} else {
Some(one_of_schemas)
},
..Schema::new(SchemaType::Object)
}
}
}
pub fn parse_struct_to_schema(
struct_item: &syn::ItemStruct,
known_schemas: &HashMap<String, String>,
) -> Schema {
let mut properties = BTreeMap::new();
let mut required = Vec::new();
let rename_all = extract_rename_all(&struct_item.attrs);
match &struct_item.fields {
Fields::Named(fields_named) => {
for field in &fields_named.named {
let rust_field_name = field
.ident
.as_ref()
.map(|i| i.to_string())
.unwrap_or_else(|| "unknown".to_string());
let field_name = if let Some(renamed) = extract_field_rename(&field.attrs) {
renamed
} else {
rename_field(&rust_field_name, rename_all.as_deref())
};
let field_type = &field.ty;
let schema_ref = parse_type_to_schema_ref(field_type, known_schemas);
properties.insert(field_name.clone(), schema_ref);
let is_optional = matches!(
field_type,
Type::Path(type_path)
if type_path
.path
.segments
.first()
.map(|s| s.ident == "Option")
.unwrap_or(false)
);
if !is_optional {
required.push(field_name);
}
}
}
Fields::Unnamed(_) => {
}
Fields::Unit => {
}
}
Schema {
schema_type: Some(SchemaType::Object),
properties: if properties.is_empty() {
None
} else {
Some(properties)
},
required: if required.is_empty() {
None
} else {
Some(required)
},
..Schema::object()
}
}
pub fn parse_type_to_schema_ref(ty: &Type, known_schemas: &HashMap<String, String>) -> SchemaRef {
match ty {
Type::Path(type_path) => {
let path = &type_path.path;
if path.segments.is_empty() {
return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object)));
}
let segment = path.segments.last().unwrap();
let ident_str = segment.ident.to_string();
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
match ident_str.as_str() {
"Vec" | "Option" => {
if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() {
let inner_schema = parse_type_to_schema_ref(inner_ty, known_schemas);
if ident_str == "Vec" {
return SchemaRef::Inline(Box::new(Schema::array(inner_schema)));
} else {
if let SchemaRef::Inline(mut schema) = inner_schema {
schema.nullable = Some(true);
return SchemaRef::Inline(schema);
}
}
}
}
"HashMap" | "BTreeMap" => {
if args.args.len() >= 2
&& let (
Some(syn::GenericArgument::Type(_key_ty)),
Some(syn::GenericArgument::Type(value_ty)),
) = (args.args.get(0), args.args.get(1))
{
let value_schema =
parse_type_to_schema_ref(value_ty, known_schemas);
let additional_props_value = match value_schema {
SchemaRef::Ref(ref_ref) => {
serde_json::json!({ "$ref": ref_ref.ref_path })
}
SchemaRef::Inline(schema) => serde_json::to_value(&*schema)
.unwrap_or(serde_json::json!({})),
};
return SchemaRef::Inline(Box::new(Schema {
schema_type: Some(SchemaType::Object),
additional_properties: Some(additional_props_value),
..Schema::object()
}));
}
}
_ => {}
}
}
match ident_str.as_str() {
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => {
SchemaRef::Inline(Box::new(Schema::integer()))
}
"f32" | "f64" => SchemaRef::Inline(Box::new(Schema::number())),
"bool" => SchemaRef::Inline(Box::new(Schema::boolean())),
"String" | "str" => SchemaRef::Inline(Box::new(Schema::string())),
"Vec" | "Option" | "Result" | "Json" | "Path" | "Query" | "Header" => {
SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object)))
}
_ => {
let type_name = if path.segments.len() > 1 {
ident_str.clone()
} else {
ident_str.clone()
};
if known_schemas.contains_key(&type_name) {
SchemaRef::Ref(Reference::schema(&type_name))
} else {
SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object)))
}
}
}
}
Type::Reference(type_ref) => {
parse_type_to_schema_ref(&type_ref.elem, known_schemas)
}
_ => SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))),
}
}
pub fn parse_request_body(
arg: &FnArg,
known_schemas: &HashMap<String, String>,
) -> Option<RequestBody> {
match arg {
FnArg::Receiver(_) => None,
FnArg::Typed(PatType { ty, .. }) => {
if let Type::Path(type_path) = ty.as_ref() {
let path = &type_path.path;
if path.segments.is_empty() {
return None;
}
let segment = &path.segments[0];
let ident_str = segment.ident.to_string();
if ident_str == "Json"
&& let syn::PathArguments::AngleBracketed(args) = &segment.arguments
&& let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first()
{
let schema = parse_type_to_schema_ref(inner_ty, known_schemas);
let mut content = BTreeMap::new();
content.insert(
"application/json".to_string(),
MediaType {
schema: Some(schema),
example: None,
examples: None,
},
);
return Some(RequestBody {
description: None,
required: Some(true),
content,
});
}
}
None
}
}
}
fn unwrap_json(ty: &Type) -> &Type {
if let Type::Path(type_path) = ty {
let path = &type_path.path;
if !path.segments.is_empty() {
let segment = &path.segments[0];
if segment.ident == "Json"
&& let syn::PathArguments::AngleBracketed(args) = &segment.arguments
&& let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() {
return inner_ty;
}
}
}
ty
}
fn extract_result_types(ty: &Type) -> Option<(Type, Type)> {
let unwrapped = unwrap_json(ty);
let result_type = match unwrapped {
Type::Path(type_path) => type_path,
Type::Reference(type_ref) => {
if let Type::Path(type_path) = type_ref.elem.as_ref() {
type_path
} else {
return None;
}
}
_ => return None,
};
let path = &result_type.path;
if path.segments.is_empty() {
return None;
}
let is_result = path.segments.iter().any(|seg| seg.ident == "Result");
if is_result {
if let Some(segment) = path.segments.last()
&& let syn::PathArguments::AngleBracketed(args) = &segment.arguments
&& args.args.len() >= 2
&& let (
Some(syn::GenericArgument::Type(ok_ty)),
Some(syn::GenericArgument::Type(err_ty)),
) = (args.args.first(), args.args.get(1))
{
let ok_ty_unwrapped = unwrap_json(ok_ty);
return Some((ok_ty_unwrapped.clone(), err_ty.clone()));
}
}
None
}
fn extract_status_code_tuple(err_ty: &Type) -> Option<(u16, Type)> {
if let Type::Tuple(tuple) = err_ty
&& tuple.elems.len() == 2 {
if let Type::Path(type_path) = &tuple.elems[0] {
let path = &type_path.path;
if !path.segments.is_empty() {
let segment = &path.segments[0];
let is_status_code = segment.ident == "StatusCode"
|| (path.segments.len() > 1
&& path.segments.iter().any(|s| s.ident == "StatusCode"));
if is_status_code {
if let Some(error_type) = tuple.elems.get(1) {
let error_type_unwrapped = unwrap_json(error_type);
return Some((400, error_type_unwrapped.clone()));
}
}
}
}
}
None
}
pub fn parse_return_type(
return_type: &ReturnType,
known_schemas: &HashMap<String, String>,
) -> BTreeMap<String, Response> {
let mut responses = BTreeMap::new();
match return_type {
ReturnType::Default => {
responses.insert(
"200".to_string(),
Response {
description: "Successful response".to_string(),
headers: None,
content: None,
},
);
}
ReturnType::Type(_, ty) => {
if let Some((ok_ty, err_ty)) = extract_result_types(ty) {
let ok_schema = parse_type_to_schema_ref(&ok_ty, known_schemas);
let mut ok_content = BTreeMap::new();
ok_content.insert(
"application/json".to_string(),
MediaType {
schema: Some(ok_schema),
example: None,
examples: None,
},
);
responses.insert(
"200".to_string(),
Response {
description: "Successful response".to_string(),
headers: None,
content: Some(ok_content),
},
);
if let Some((status_code, error_type)) = extract_status_code_tuple(&err_ty) {
let err_schema = parse_type_to_schema_ref(&error_type, known_schemas);
let mut err_content = BTreeMap::new();
err_content.insert(
"application/json".to_string(),
MediaType {
schema: Some(err_schema),
example: None,
examples: None,
},
);
responses.insert(
status_code.to_string(),
Response {
description: "Error response".to_string(),
headers: None,
content: Some(err_content),
},
);
} else {
let err_ty_unwrapped = unwrap_json(&err_ty);
let err_schema = parse_type_to_schema_ref(err_ty_unwrapped, known_schemas);
let mut err_content = BTreeMap::new();
err_content.insert(
"application/json".to_string(),
MediaType {
schema: Some(err_schema),
example: None,
examples: None,
},
);
responses.insert(
"400".to_string(),
Response {
description: "Error response".to_string(),
headers: None,
content: Some(err_content),
},
);
}
} else {
let unwrapped_ty = unwrap_json(ty);
let schema = parse_type_to_schema_ref(unwrapped_ty, known_schemas);
let mut content = BTreeMap::new();
content.insert(
"application/json".to_string(),
MediaType {
schema: Some(schema),
example: None,
examples: None,
},
);
responses.insert(
"200".to_string(),
Response {
description: "Successful response".to_string(),
headers: None,
content: Some(content),
},
);
}
}
}
responses
}
pub fn build_operation_from_function(
sig: &syn::Signature,
path: &str,
known_schemas: &HashMap<String, String>,
error_status: Option<&[u16]>,
) -> Operation {
let path_params = extract_path_parameters(path);
let mut parameters = Vec::new();
let mut request_body = None;
for input in &sig.inputs {
if let Some(body) = parse_request_body(input, known_schemas) {
request_body = Some(body);
} else if let Some(param) = parse_function_parameter(input, &path_params, known_schemas) {
parameters.push(param);
}
}
let mut responses = parse_return_type(&sig.output, known_schemas);
if let Some(status_codes) = error_status {
let error_schema = responses
.iter()
.find(|(code, _)| code != &&"200".to_string())
.and_then(|(_, resp)| {
resp.content
.as_ref()?
.get("application/json")?
.schema
.clone()
});
if let Some(schema) = error_schema {
for &status_code in status_codes {
let status_str = status_code.to_string();
responses.entry(status_str).or_insert_with(|| {
let mut err_content = BTreeMap::new();
err_content.insert(
"application/json".to_string(),
MediaType {
schema: Some(schema.clone()),
example: None,
examples: None,
},
);
Response {
description: "Error response".to_string(),
headers: None,
content: Some(err_content),
}
});
}
}
}
Operation {
operation_id: Some(sig.ident.to_string()),
tags: None,
summary: None,
description: None,
parameters: if parameters.is_empty() {
None
} else {
Some(parameters)
},
request_body,
responses,
security: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use std::collections::HashMap;
use vespera_core::schema::SchemaType;
#[rstest]
#[case("/test", vec![])]
#[case("/test/{id}", vec!["id"])]
#[case("/test/{id}/test/{test_id}", vec!["id", "test_id"])]
#[case("/test/:id/test/:test_id", vec!["id", "test_id"])]
fn test_extract_path_parameters(#[case] path: &str, #[case] expected: Vec<&str>) {
assert_eq!(extract_path_parameters(path), expected);
}
#[rstest]
#[case("", "")] #[case("-> String", "String")] #[case("-> i32", "i32")] #[case("-> bool", "bool")] #[case("-> Vec<String>", "Vec<String>")] #[case("-> Option<String>", "Option<String>")] #[case("-> Result<String, String>", "Result<String, String>")] #[case("-> Result<i32, String>", "Result<i32, String>")] #[case("-> Result<Json<User>, String>", "Result<Json<User>, String>")] #[case(
"-> Result<String, (StatusCode, String)>",
"Result<String, (StatusCode, String)>"
)] #[case("-> &str", "&str")] #[case("-> Result<&str, String>", "Result<&str, String>")] fn test_parse_return_type(#[case] return_type_str: &str, #[case] expected_type: &str) {
let known_schemas = HashMap::new();
let return_type = if return_type_str.is_empty() {
ReturnType::Default
} else {
let full_signature = format!("fn test() {}", return_type_str);
let parsed: syn::Signature =
syn::parse_str(&full_signature).expect("Failed to parse return type");
parsed.output
};
let responses = parse_return_type(&return_type, &known_schemas);
match expected_type {
"" => {
assert_eq!(responses.len(), 1);
assert!(responses.contains_key("200"));
let response = responses.get("200").unwrap();
assert_eq!(response.description, "Successful response");
assert!(response.content.is_none());
}
"String" | "&str" => {
assert_eq!(responses.len(), 1);
assert!(responses.contains_key("200"));
let response = responses.get("200").unwrap();
assert_eq!(response.description, "Successful response");
assert!(response.content.is_some());
let content = response.content.as_ref().unwrap();
assert!(content.contains_key("application/json"));
let media_type = content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(SchemaType::String));
} else {
panic!("Expected inline String schema");
}
}
"i32" => {
assert_eq!(responses.len(), 1);
assert!(responses.contains_key("200"));
let response = responses.get("200").unwrap();
assert_eq!(response.description, "Successful response");
assert!(response.content.is_some());
let content = response.content.as_ref().unwrap();
assert!(content.contains_key("application/json"));
let media_type = content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(SchemaType::Integer));
} else {
panic!("Expected inline Integer schema");
}
}
"bool" => {
assert_eq!(responses.len(), 1);
assert!(responses.contains_key("200"));
let response = responses.get("200").unwrap();
assert_eq!(response.description, "Successful response");
assert!(response.content.is_some());
let content = response.content.as_ref().unwrap();
assert!(content.contains_key("application/json"));
let media_type = content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(SchemaType::Boolean));
} else {
panic!("Expected inline Boolean schema");
}
}
"Vec<String>" => {
assert_eq!(responses.len(), 1);
assert!(responses.contains_key("200"));
let response = responses.get("200").unwrap();
assert_eq!(response.description, "Successful response");
assert!(response.content.is_some());
let content = response.content.as_ref().unwrap();
assert!(content.contains_key("application/json"));
let media_type = content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(SchemaType::Array));
assert!(schema.items.is_some());
if let Some(items) = &schema.items {
if let SchemaRef::Inline(items_schema) = items.as_ref() {
assert_eq!(items_schema.schema_type, Some(SchemaType::String));
}
}
} else {
panic!("Expected inline Array schema");
}
}
"Option<String>" => {
assert_eq!(responses.len(), 1);
assert!(responses.contains_key("200"));
let response = responses.get("200").unwrap();
assert_eq!(response.description, "Successful response");
assert!(response.content.is_some());
let content = response.content.as_ref().unwrap();
assert!(content.contains_key("application/json"));
let media_type = content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() {
assert_eq!(schema.nullable, Some(true));
assert_eq!(schema.schema_type, Some(SchemaType::String));
} else {
panic!("Expected inline nullable String schema");
}
}
"Result<String, String>" => {
assert_eq!(responses.len(), 2);
assert!(responses.contains_key("200"));
assert!(responses.contains_key("400"));
let ok_response = responses.get("200").unwrap();
assert_eq!(ok_response.description, "Successful response");
assert!(ok_response.content.is_some());
let ok_content = ok_response.content.as_ref().unwrap();
let ok_media_type = ok_content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(SchemaType::String));
} else {
panic!("Expected inline String schema for Ok type");
}
let err_response = responses.get("400").unwrap();
assert_eq!(err_response.description, "Error response");
assert!(err_response.content.is_some());
let err_content = err_response.content.as_ref().unwrap();
let err_media_type = err_content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(SchemaType::String));
} else {
panic!("Expected inline String schema for Err type");
}
}
"Result<i32, String>" => {
assert_eq!(responses.len(), 2);
assert!(responses.contains_key("200"));
assert!(responses.contains_key("400"));
let ok_response = responses.get("200").unwrap();
assert_eq!(ok_response.description, "Successful response");
assert!(ok_response.content.is_some());
let ok_content = ok_response.content.as_ref().unwrap();
let ok_media_type = ok_content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(SchemaType::Integer));
} else {
panic!("Expected inline Integer schema for Ok type");
}
let err_response = responses.get("400").unwrap();
assert_eq!(err_response.description, "Error response");
assert!(err_response.content.is_some());
let err_content = err_response.content.as_ref().unwrap();
let err_media_type = err_content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(SchemaType::String));
} else {
panic!("Expected inline String schema for Err type");
}
}
"Result<Json<User>, String>" => {
assert_eq!(responses.len(), 2);
assert!(responses.contains_key("200"));
assert!(responses.contains_key("400"));
let ok_response = responses.get("200").unwrap();
assert_eq!(ok_response.description, "Successful response");
assert!(ok_response.content.is_some());
let ok_content = ok_response.content.as_ref().unwrap();
let ok_media_type = ok_content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(SchemaType::Object));
} else {
panic!("Expected inline Object schema for User type");
}
let err_response = responses.get("400").unwrap();
assert_eq!(err_response.description, "Error response");
assert!(err_response.content.is_some());
let err_content = err_response.content.as_ref().unwrap();
let err_media_type = err_content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(SchemaType::String));
} else {
panic!("Expected inline String schema for Err type");
}
}
"Result<&str, String>" => {
assert_eq!(responses.len(), 2);
assert!(responses.contains_key("200"));
assert!(responses.contains_key("400"));
let ok_response = responses.get("200").unwrap();
assert_eq!(ok_response.description, "Successful response");
assert!(ok_response.content.is_some());
let ok_content = ok_response.content.as_ref().unwrap();
let ok_media_type = ok_content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(SchemaType::String));
} else {
panic!("Expected inline String schema for &str type");
}
let err_response = responses.get("400").unwrap();
assert_eq!(err_response.description, "Error response");
assert!(err_response.content.is_some());
let err_content = err_response.content.as_ref().unwrap();
let err_media_type = err_content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(SchemaType::String));
} else {
panic!("Expected inline String schema for Err type");
}
}
"Result<String, (StatusCode, String)>" => {
assert_eq!(responses.len(), 2);
assert!(responses.contains_key("200"));
assert!(responses.contains_key("400"));
let ok_response = responses.get("200").unwrap();
assert_eq!(ok_response.description, "Successful response");
let ok_content = ok_response.content.as_ref().unwrap();
let ok_media_type = ok_content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = ok_media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(SchemaType::String));
} else {
panic!("Expected inline String schema for Ok type");
}
let err_response = responses.get("400").unwrap();
assert_eq!(err_response.description, "Error response");
let err_content = err_response.content.as_ref().unwrap();
let err_media_type = err_content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = err_media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(SchemaType::String));
} else {
panic!("Expected inline String schema for Err type");
}
}
_ => panic!("Unexpected test case"),
}
}
#[test]
fn test_parse_return_type_with_known_schema() {
let mut known_schemas = HashMap::new();
known_schemas.insert("User".to_string(), "User".to_string());
{
let return_type_str = "-> User";
let full_signature = format!("fn test() {}", return_type_str);
let parsed: syn::Signature =
syn::parse_str(&full_signature).expect("Failed to parse return type");
let responses = parse_return_type(&parsed.output, &known_schemas);
assert_eq!(responses.len(), 1);
assert!(responses.contains_key("200"));
let response = responses.get("200").unwrap();
assert!(response.content.is_some());
let content = response.content.as_ref().unwrap();
let media_type = content.get("application/json").unwrap();
if let SchemaRef::Ref(ref_ref) = media_type.schema.as_ref().unwrap() {
assert_eq!(ref_ref.ref_path, "#/components/schemas/User");
} else {
panic!("Expected schema reference for known type");
}
}
{
let return_type_str = "-> Json<User>";
let full_signature = format!("fn test() {}", return_type_str);
let parsed: syn::Signature =
syn::parse_str(&full_signature).expect("Failed to parse return type");
println!("parsed: {:?}", parsed.output);
let responses = parse_return_type(&parsed.output, &known_schemas);
println!("responses: {:?}", responses);
assert_eq!(responses.len(), 1);
assert!(responses.contains_key("200"));
let response = responses.get("200").unwrap();
assert!(response.content.is_some());
let content = response.content.as_ref().unwrap();
let media_type = content.get("application/json").unwrap();
if let SchemaRef::Ref(ref_ref) = media_type.schema.as_ref().unwrap() {
assert_eq!(ref_ref.ref_path, "#/components/schemas/User");
} else {
panic!("Expected schema reference for Json<User>");
}
}
}
#[test]
fn test_parse_return_type_result_with_known_schema() {
let mut known_schemas = HashMap::new();
known_schemas.insert("User".to_string(), "User".to_string());
known_schemas.insert("Error".to_string(), "Error".to_string());
let return_type_str = "-> Result<User, Error>";
let full_signature = format!("fn test() {}", return_type_str);
let parsed: syn::Signature =
syn::parse_str(&full_signature).expect("Failed to parse return type");
let responses = parse_return_type(&parsed.output, &known_schemas);
assert_eq!(responses.len(), 2);
assert!(responses.contains_key("200"));
assert!(responses.contains_key("400"));
let ok_response = responses.get("200").unwrap();
let ok_content = ok_response.content.as_ref().unwrap();
let ok_media_type = ok_content.get("application/json").unwrap();
if let SchemaRef::Ref(ref_ref) = ok_media_type.schema.as_ref().unwrap() {
assert_eq!(ref_ref.ref_path, "#/components/schemas/User");
} else {
panic!("Expected schema reference for User");
}
let err_response = responses.get("400").unwrap();
let err_content = err_response.content.as_ref().unwrap();
let err_media_type = err_content.get("application/json").unwrap();
if let SchemaRef::Ref(ref_ref) = err_media_type.schema.as_ref().unwrap() {
assert_eq!(ref_ref.ref_path, "#/components/schemas/Error");
} else {
panic!("Expected schema reference for Error");
}
}
#[test]
fn test_parse_return_type_primitive_types() {
let known_schemas = HashMap::new();
let test_cases = vec![
("-> i8", SchemaType::Integer),
("-> i16", SchemaType::Integer),
("-> i32", SchemaType::Integer),
("-> i64", SchemaType::Integer),
("-> u8", SchemaType::Integer),
("-> u16", SchemaType::Integer),
("-> u32", SchemaType::Integer),
("-> u64", SchemaType::Integer),
("-> f32", SchemaType::Number),
("-> f64", SchemaType::Number),
("-> bool", SchemaType::Boolean),
("-> String", SchemaType::String),
];
for (return_type_str, expected_schema_type) in test_cases {
let full_signature = format!("fn test() {}", return_type_str);
let parsed: syn::Signature = syn::parse_str(&full_signature)
.expect(&format!("Failed to parse return type: {}", return_type_str));
let responses = parse_return_type(&parsed.output, &known_schemas);
assert_eq!(responses.len(), 1);
let response = responses.get("200").unwrap();
let content = response.content.as_ref().unwrap();
let media_type = content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(expected_schema_type));
} else {
panic!(
"Expected inline schema for primitive type: {}",
return_type_str
);
}
}
}
#[test]
fn test_parse_return_type_array() {
let known_schemas = HashMap::new();
let return_type_str = "-> Vec<String>";
let full_signature = format!("fn test() {}", return_type_str);
let parsed: syn::Signature =
syn::parse_str(&full_signature).expect("Failed to parse return type");
let responses = parse_return_type(&parsed.output, &known_schemas);
assert_eq!(responses.len(), 1);
let response = responses.get("200").unwrap();
let content = response.content.as_ref().unwrap();
let media_type = content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() {
assert_eq!(schema.schema_type, Some(SchemaType::Array));
assert!(schema.items.is_some());
} else {
panic!("Expected inline array schema");
}
}
#[test]
fn test_parse_return_type_option() {
let known_schemas = HashMap::new();
let return_type_str = "-> Option<String>";
let full_signature = format!("fn test() {}", return_type_str);
let parsed: syn::Signature =
syn::parse_str(&full_signature).expect("Failed to parse return type");
let responses = parse_return_type(&parsed.output, &known_schemas);
assert_eq!(responses.len(), 1);
let response = responses.get("200").unwrap();
let content = response.content.as_ref().unwrap();
let media_type = content.get("application/json").unwrap();
if let SchemaRef::Inline(schema) = media_type.schema.as_ref().unwrap() {
assert_eq!(schema.nullable, Some(true));
if let Some(items) = &schema.items {
if let SchemaRef::Inline(inner_schema) = items.as_ref() {
assert_eq!(inner_schema.schema_type, Some(SchemaType::String));
}
}
} else {
panic!("Expected inline nullable schema");
}
}
#[rstest]
#[case("user_name", Some("camelCase"), "userName")]
#[case("first_name", Some("camelCase"), "firstName")]
#[case("last_name", Some("camelCase"), "lastName")]
#[case("user_id", Some("camelCase"), "userId")]
#[case("api_key", Some("camelCase"), "apiKey")]
#[case("already_camel", Some("camelCase"), "alreadyCamel")]
#[case("userName", Some("snake_case"), "user_name")]
#[case("firstName", Some("snake_case"), "first_name")]
#[case("lastName", Some("snake_case"), "last_name")]
#[case("userId", Some("snake_case"), "user_id")]
#[case("apiKey", Some("snake_case"), "api_key")]
#[case("already_snake", Some("snake_case"), "already_snake")]
#[case("user_name", Some("kebab-case"), "user-name")]
#[case("first_name", Some("kebab-case"), "first-name")]
#[case("last_name", Some("kebab-case"), "last-name")]
#[case("user_id", Some("kebab-case"), "user-id")]
#[case("api_key", Some("kebab-case"), "api-key")]
#[case("already-kebab", Some("kebab-case"), "already-kebab")]
#[case("user_name", Some("PascalCase"), "UserName")]
#[case("first_name", Some("PascalCase"), "FirstName")]
#[case("last_name", Some("PascalCase"), "LastName")]
#[case("user_id", Some("PascalCase"), "UserId")]
#[case("api_key", Some("PascalCase"), "ApiKey")]
#[case("AlreadyPascal", Some("PascalCase"), "AlreadyPascal")]
#[case("UserName", Some("lowercase"), "username")]
#[case("FIRST_NAME", Some("lowercase"), "first_name")]
#[case("lastName", Some("lowercase"), "lastname")]
#[case("User_ID", Some("lowercase"), "user_id")]
#[case("API_KEY", Some("lowercase"), "api_key")]
#[case("already_lower", Some("lowercase"), "already_lower")]
#[case("user_name", Some("UPPERCASE"), "USER_NAME")]
#[case("firstName", Some("UPPERCASE"), "FIRSTNAME")]
#[case("LastName", Some("UPPERCASE"), "LASTNAME")]
#[case("user_id", Some("UPPERCASE"), "USER_ID")]
#[case("apiKey", Some("UPPERCASE"), "APIKEY")]
#[case("ALREADY_UPPER", Some("UPPERCASE"), "ALREADY_UPPER")]
#[case("user_name", Some("SCREAMING_SNAKE_CASE"), "USER_NAME")]
#[case("firstName", Some("SCREAMING_SNAKE_CASE"), "FIRST_NAME")]
#[case("LastName", Some("SCREAMING_SNAKE_CASE"), "LAST_NAME")]
#[case("user_id", Some("SCREAMING_SNAKE_CASE"), "USER_ID")]
#[case("apiKey", Some("SCREAMING_SNAKE_CASE"), "API_KEY")]
#[case("ALREADY_SCREAMING", Some("SCREAMING_SNAKE_CASE"), "ALREADY_SCREAMING")]
#[case("user_name", Some("SCREAMING-KEBAB-CASE"), "USER-NAME")]
#[case("firstName", Some("SCREAMING-KEBAB-CASE"), "FIRST-NAME")]
#[case("LastName", Some("SCREAMING-KEBAB-CASE"), "LAST-NAME")]
#[case("user_id", Some("SCREAMING-KEBAB-CASE"), "USER-ID")]
#[case("apiKey", Some("SCREAMING-KEBAB-CASE"), "API-KEY")]
#[case("already-kebab", Some("SCREAMING-KEBAB-CASE"), "ALREADY-KEBAB")]
#[case("user_name", None, "user_name")]
#[case("firstName", None, "firstName")]
#[case("LastName", None, "LastName")]
#[case("user-id", None, "user-id")]
fn test_rename_field(
#[case] field_name: &str,
#[case] rename_all: Option<&str>,
#[case] expected: &str,
) {
assert_eq!(rename_field(field_name, rename_all), expected);
}
}