use std::cell::OnceCell;
use std::collections::{BTreeMap, HashSet};
use syn::{FnArg, PatType, Type};
use vespera_core::route::{MediaType, Operation, Parameter, ParameterLocation, Response};
use vespera_core::schema::{Reference, Schema, SchemaRef, SchemaType};
use crate::metadata::HeaderParam;
use super::{
extractors::{is_validated_type, unwrap_validated_type},
parameters::parse_function_parameter,
path::extract_path_parameters,
request_body::parse_request_body,
response::parse_return_type,
schema::parse_type_to_schema_ref,
};
#[derive(Clone, Copy, Default)]
pub struct OperationRouteConfig<'a> {
pub error_status: Option<&'a [u16]>,
pub typed_responses: Option<&'a [(u16, String)]>,
pub success_status: Option<u16>,
pub tags: Option<&'a [String]>,
pub security: Option<&'a [String]>,
pub headers: Option<&'a [HeaderParam]>,
pub operation_id: Option<&'a str>,
pub summary: Option<&'a str>,
pub request_example: Option<&'a serde_json::Value>,
pub response_example: Option<&'a serde_json::Value>,
pub deprecated: bool,
}
#[allow(clippy::too_many_lines)]
pub fn build_operation_from_function(
sig: &syn::Signature,
path: &str,
known_schemas: &HashSet<&str>,
struct_definitions: &std::collections::HashMap<&str, &str>,
config: OperationRouteConfig<'_>,
) -> Operation {
let path_params = extract_path_parameters(path);
let mut parameters = Vec::new();
let mut request_body = None;
let mut path_extractor_type: Option<Type> = None;
let mut has_validated_extractor = false;
let string_type: OnceCell<Type> = OnceCell::new();
for input in &sig.inputs {
if let FnArg::Typed(PatType { ty, .. }) = input
&& let Type::Path(type_path) = unwrap_validated_type(ty.as_ref())
{
has_validated_extractor |= is_validated_type(ty.as_ref());
if let Some(segment) = type_path.path.segments.last()
&& segment.ident == "Path"
&& let syn::PathArguments::AngleBracketed(args) = &segment.arguments
&& let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first()
{
path_extractor_type = Some(inner_ty.clone());
break;
}
}
}
if !path_params.is_empty() {
if let Some(ty) = path_extractor_type {
if let Type::Tuple(tuple) = ty {
for (idx, param_name) in path_params.iter().enumerate() {
if let Some(elem_ty) = tuple.elems.get(idx) {
parameters.push(Parameter {
name: param_name.clone(),
r#in: ParameterLocation::Path,
description: None,
required: Some(true),
schema: Some(parse_type_to_schema_ref(
elem_ty,
known_schemas,
struct_definitions,
)),
example: None,
});
} else {
parameters.push(Parameter {
name: param_name.clone(),
r#in: ParameterLocation::Path,
description: None,
required: Some(true),
schema: Some(parse_type_to_schema_ref(
string_type.get_or_init(string_type_ast),
known_schemas,
struct_definitions,
)),
example: None,
});
}
}
} else {
if path_params.len() == 1 {
parameters.push(Parameter {
name: path_params[0].clone(),
r#in: ParameterLocation::Path,
description: None,
required: Some(true),
schema: Some(parse_type_to_schema_ref(
&ty,
known_schemas,
struct_definitions,
)),
example: None,
});
} else {
for param_name in &path_params {
parameters.push(Parameter {
name: param_name.clone(),
r#in: ParameterLocation::Path,
description: None,
required: Some(true),
schema: Some(parse_type_to_schema_ref(
&ty,
known_schemas,
struct_definitions,
)),
example: None,
});
}
}
}
} else {
for param_name in &path_params {
parameters.push(Parameter {
name: param_name.clone(),
r#in: ParameterLocation::Path,
description: None,
required: Some(true),
schema: Some(parse_type_to_schema_ref(
string_type.get_or_init(string_type_ast),
known_schemas,
struct_definitions,
)),
example: None,
});
}
}
}
let path_param_set: HashSet<&str> = path_params.iter().map(String::as_str).collect();
for input in &sig.inputs {
if let Some(body) = parse_request_body(input, known_schemas, struct_definitions) {
request_body = Some(body);
} else {
let is_path_extractor = if let FnArg::Typed(PatType { ty, .. }) = input
&& let Type::Path(type_path) = unwrap_validated_type(ty.as_ref())
&& let Some(segment) = type_path.path.segments.last()
{
segment.ident == "Path"
} else {
false
};
if !is_path_extractor
&& let Some(params) = parse_function_parameter(
input,
&path_params,
&path_param_set,
known_schemas,
struct_definitions,
)
{
parameters.extend(params);
}
}
}
if let Some(headers) = config.headers {
parameters.extend(headers.iter().map(header_parameter));
}
deduplicate_header_parameters(&mut parameters);
let mut responses = parse_return_type(&sig.output, known_schemas, struct_definitions);
if let Some(example) = config.request_example
&& let Some(body) = request_body.as_mut()
{
for media in body.content.values_mut() {
media.example = Some(example.clone());
}
}
if let Some(status_codes) = config.error_status {
let error_media = responses
.iter()
.find(|(code, _)| code.as_str() != "200")
.and_then(|(_, resp)| resp.content.as_ref()?.iter().next())
.map(|(content_type, media)| (content_type.clone(), media.schema.clone()));
if let Some((content_type, schema)) = error_media {
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(
content_type.clone(),
MediaType {
schema: schema.clone(),
example: None,
examples: None,
},
);
Response {
description: error_response_description(),
headers: None,
content: Some(err_content),
}
});
}
}
}
if let Some(typed_responses) = config.typed_responses {
for (status_code, schema_name) in typed_responses {
responses.insert(
status_code.to_string(),
typed_response(schema_name, response_description_for_status(*status_code)),
);
}
}
let declares_errors = config.typed_responses.is_some_and(|r| !r.is_empty())
|| config.error_status.is_some_and(|s| !s.is_empty());
if declares_errors {
let declares_400 = config
.typed_responses
.is_some_and(|typed| typed.iter().any(|(code, _)| *code == 400))
|| config
.error_status
.is_some_and(|codes| codes.contains(&400));
if !declares_400 {
responses.remove("400");
}
}
if has_validated_extractor {
responses
.entry("422".to_string())
.or_insert_with(validation_error_response);
}
if let Some(example) = config.response_example
&& let Some(response) = responses.get_mut("200")
&& let Some(content) = response.content.as_mut()
{
for media in content.values_mut() {
media.example = Some(example.clone());
}
}
if let Some(success) = config.success_status
&& success != 200
&& let Some(mut response) = responses.remove("200")
{
if matches!(success, 204 | 304) {
response.content = None;
}
responses.insert(success.to_string(), response);
}
Operation {
operation_id: config
.operation_id
.map(str::to_owned)
.or_else(|| Some(sig.ident.to_string())),
tags: config.tags.map(<[std::string::String]>::to_vec),
summary: config.summary.map(str::to_owned),
description: None,
parameters: if parameters.is_empty() {
None
} else {
Some(parameters)
},
request_body,
responses,
security: config.security.map(security_requirements),
deprecated: config.deprecated.then_some(true),
external_docs: None,
callbacks: None,
servers: None,
}
}
fn string_type_ast() -> Type {
Type::Path(syn::TypePath {
attrs: Vec::new(),
qself: None,
path: syn::Path::from(syn::Ident::new("String", proc_macro2::Span::call_site())),
})
}
fn header_parameter(header: &HeaderParam) -> Parameter {
Parameter {
name: header.name.clone(),
r#in: ParameterLocation::Header,
description: header.description.clone(),
required: Some(header.required),
schema: Some(SchemaRef::Inline(Box::new(Schema {
schema_type: Some(SchemaType::String),
..Schema::default()
}))),
example: None,
}
}
fn error_response_description() -> String {
"Error response".to_string()
}
fn response_description_for_status(status_code: u16) -> String {
if (200..300).contains(&status_code) {
"Successful response".to_string()
} else {
error_response_description()
}
}
fn deduplicate_header_parameters(parameters: &mut Vec<Parameter>) {
let mut seen_headers = HashSet::new();
parameters.retain(|parameter| {
if parameter.r#in != ParameterLocation::Header {
return true;
}
seen_headers.insert(parameter.name.to_ascii_lowercase())
});
}
fn typed_response(schema_name: &str, description: String) -> Response {
let mut content = BTreeMap::new();
content.insert(
"application/json".to_string(),
MediaType {
schema: Some(SchemaRef::Ref(Reference::schema(schema_name))),
example: None,
examples: None,
},
);
Response {
description,
headers: None,
content: Some(content),
}
}
fn validation_error_response() -> Response {
let mut error_properties = BTreeMap::new();
error_properties.insert(
"path".to_string(),
SchemaRef::Inline(Box::new(Schema::string())),
);
error_properties.insert(
"message".to_string(),
SchemaRef::Inline(Box::new(Schema::string())),
);
let error_item = SchemaRef::Inline(Box::new(Schema {
schema_type: Some(SchemaType::Object),
properties: Some(error_properties),
required: Some(vec!["path".to_string(), "message".to_string()]),
..Schema::default()
}));
let mut response_properties = BTreeMap::new();
response_properties.insert(
"errors".to_string(),
SchemaRef::Inline(Box::new(Schema {
schema_type: Some(SchemaType::Array),
items: Some(error_item),
..Schema::default()
})),
);
let mut content = BTreeMap::new();
content.insert(
"application/json".to_string(),
MediaType {
schema: Some(SchemaRef::Inline(Box::new(Schema {
schema_type: Some(SchemaType::Object),
properties: Some(response_properties),
required: Some(vec!["errors".to_string()]),
..Schema::default()
}))),
example: None,
examples: None,
},
);
Response {
description: "Validation failed".to_string(),
headers: None,
content: Some(content),
}
}
fn security_requirements(security: &[String]) -> Vec<BTreeMap<String, Vec<String>>> {
security
.iter()
.map(|scheme| BTreeMap::from([(scheme.clone(), Vec::new())]))
.collect()
}
#[cfg(test)]
mod tests;