Skip to main content

ultraapi_macros/
lib.rs

1use proc_macro::TokenStream;
2use quote::{format_ident, quote};
3use syn::{
4    parse::Parser, parse_macro_input, FnArg, ItemEnum, ItemFn, ItemStruct, LitInt, LitStr, PatType,
5    Path, PathSegment, Type,
6};
7
8fn extract_inner_type(seg: &PathSegment) -> Option<&Type> {
9    if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
10        if let Some(syn::GenericArgument::Type(inner)) = args.args.first() {
11            return Some(inner);
12        }
13    }
14    None
15}
16
17fn is_dep_type(ty: &Type) -> bool {
18    if let Type::Path(tp) = ty {
19        if let Some(seg) = tp.path.segments.last() {
20            return seg.ident == "Dep";
21        }
22    }
23    false
24}
25
26fn is_state_type(ty: &Type) -> bool {
27    if let Type::Path(tp) = ty {
28        if let Some(seg) = tp.path.segments.last() {
29            return seg.ident == "State";
30        }
31    }
32    false
33}
34
35fn is_query_type(ty: &Type) -> bool {
36    if let Type::Path(tp) = ty {
37        if let Some(seg) = tp.path.segments.last() {
38            return seg.ident == "Query";
39        }
40    }
41    false
42}
43
44fn get_type_name(ty: &Type) -> String {
45    if let Type::Path(tp) = ty {
46        if let Some(seg) = tp.path.segments.last() {
47            return seg.ident.to_string();
48        }
49    }
50    "Unknown".to_string()
51}
52
53/// Check if the type is Result<T, E> and return the Ok type
54fn get_result_ok_type(ty: &Type) -> Option<&Type> {
55    if let Type::Path(tp) = ty {
56        if let Some(seg) = tp.path.segments.last() {
57            if seg.ident == "Result" {
58                if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
59                    if let Some(syn::GenericArgument::Type(ok_type)) = args.args.first() {
60                        return Some(ok_type);
61                    }
62                }
63            }
64        }
65    }
66    None
67}
68
69/// Check if the type is Vec<T> and return the inner type name
70fn get_vec_inner_type_name(ty: &Type) -> Option<String> {
71    if let Type::Path(tp) = ty {
72        if let Some(seg) = tp.path.segments.last() {
73            if seg.ident == "Vec" {
74                if let Some(inner) = extract_inner_type(seg) {
75                    return Some(get_type_name(inner));
76                }
77            }
78        }
79    }
80    None
81}
82
83fn is_primitive_type(ty: &Type) -> bool {
84    let name = get_type_name(ty);
85    matches!(
86        name.as_str(),
87        "i8" | "i16"
88            | "i32"
89            | "i64"
90            | "i128"
91            | "u8"
92            | "u16"
93            | "u32"
94            | "u64"
95            | "u128"
96            | "f32"
97            | "f64"
98            | "String"
99            | "bool"
100    )
101}
102
103/// Extract doc comment string from attributes
104fn extract_doc_comment(attrs: &[syn::Attribute]) -> String {
105    let mut lines = Vec::new();
106    for attr in attrs {
107        if attr.path().is_ident("doc") {
108            if let syn::Meta::NameValue(nv) = &attr.meta {
109                if let syn::Expr::Lit(syn::ExprLit {
110                    lit: syn::Lit::Str(s),
111                    ..
112                }) = &nv.value
113                {
114                    lines.push(s.value().trim().to_string());
115                }
116            }
117        }
118    }
119    lines.join("\n").trim().to_string()
120}
121
122fn route_macro_impl(method: &str, attr: TokenStream, item: TokenStream) -> TokenStream {
123    let path = parse_macro_input!(attr as LitStr).value();
124    let input_fn = parse_macro_input!(item as ItemFn);
125    let fn_name = &input_fn.sig.ident;
126    let fn_vis = &input_fn.vis;
127    let fn_sig = &input_fn.sig;
128    let fn_block = &input_fn.block;
129
130    // Parse custom attributes: #[status(N)], #[tag("x")], #[security("x")], doc comments
131    let mut status_code: Option<u16> = None;
132    let mut tags: Vec<String> = Vec::new();
133    let mut security_schemes: Vec<String> = Vec::new();
134    let description = extract_doc_comment(&input_fn.attrs);
135
136    let mut clean_attrs: Vec<&syn::Attribute> = Vec::new();
137    for attr in &input_fn.attrs {
138        if attr.path().is_ident("status") {
139            if let syn::Meta::List(list) = &attr.meta {
140                let tokens = list.tokens.clone();
141                if let Ok(lit) = syn::parse2::<LitInt>(tokens) {
142                    status_code = Some(lit.base10_parse().unwrap());
143                }
144            }
145        } else if attr.path().is_ident("tag") {
146            if let syn::Meta::List(list) = &attr.meta {
147                let tokens = list.tokens.clone();
148                if let Ok(lit) = syn::parse2::<LitStr>(tokens) {
149                    tags.push(lit.value());
150                }
151            }
152        } else if attr.path().is_ident("security") {
153            if let syn::Meta::List(list) = &attr.meta {
154                let tokens = list.tokens.clone();
155                if let Ok(lit) = syn::parse2::<LitStr>(tokens) {
156                    security_schemes.push(lit.value());
157                }
158            }
159        } else {
160            // Not doc, not status/tag/security - keep it (e.g. serde, schemars)
161            clean_attrs.push(attr);
162        }
163    }
164
165    // Default status codes
166    let default_status: u16 = match method {
167        "post" => 201,
168        "delete" => 204,
169        _ => 200,
170    };
171    let success_status = status_code.unwrap_or(default_status);
172
173    let path_params: Vec<String> = path
174        .split('/')
175        .filter(|s| s.starts_with('{') && s.ends_with('}'))
176        .map(|s| s[1..s.len() - 1].to_string())
177        .collect();
178
179    let axum_path = path.clone();
180    let method_upper = method.to_uppercase();
181    let method_ident = format_ident!("{}", method.to_lowercase());
182    let wrapper_name = format_ident!("__{}_axum_handler", fn_name);
183    let route_info_name = format_ident!("__{}_route_info", fn_name);
184    let route_ref_name = format_ident!("__ULTRAAPI_ROUTE_{}", fn_name.to_string().to_uppercase());
185    let hayai_route_ref_name =
186        format_ident!("__HAYAI_ROUTE_{}", fn_name.to_string().to_uppercase());
187
188    let mut dep_extractions = Vec::new();
189    let mut call_args = Vec::new();
190    let mut has_body = false;
191    let mut body_type: Option<&Type> = None;
192    let mut path_param_types: Vec<(&syn::Ident, &Type)> = Vec::new();
193    let mut query_type: Option<&Type> = None;
194    let mut query_extraction = quote! {};
195
196    for arg in &input_fn.sig.inputs {
197        if let FnArg::Typed(PatType { pat, ty, .. }) = arg {
198            let param_name = quote!(#pat).to_string();
199            if is_dep_type(ty) {
200                if let Type::Path(tp) = ty.as_ref() {
201                    if let Some(seg) = tp.path.segments.last() {
202                        if let Some(inner) = extract_inner_type(seg) {
203                            dep_extractions.push(quote! {
204                                let #pat: ultraapi::Dep<#inner> = ultraapi::Dep::from_app_state(&state)?;
205                            });
206                            call_args.push(quote!(#pat));
207                        }
208                    }
209                }
210            } else if is_state_type(ty) {
211                if let Type::Path(tp) = ty.as_ref() {
212                    if let Some(seg) = tp.path.segments.last() {
213                        if let Some(inner) = extract_inner_type(seg) {
214                            dep_extractions.push(quote! {
215                                let #pat: ultraapi::State<#inner> = ultraapi::State::from_app_state(&state)?;
216                            });
217                            call_args.push(quote!(#pat));
218                        }
219                    }
220                }
221            } else if is_query_type(ty) {
222                if let Type::Path(tp) = ty.as_ref() {
223                    if let Some(seg) = tp.path.segments.last() {
224                        if let Some(inner) = extract_inner_type(seg) {
225                            query_type = Some(inner);
226                            query_extraction = quote! {
227                                let #pat: ultraapi::axum::extract::Query<#inner> =
228                                    ultraapi::axum::extract::Query::from_request_parts(&mut parts, &state).await
229                                    .map_err(|e| ultraapi::ApiError::bad_request(format!("Invalid query parameters: {}", e)))?;
230                            };
231                            call_args.push(quote!(#pat));
232                        }
233                    }
234                }
235            } else if path_params.contains(&param_name) {
236                if let syn::Pat::Ident(pi) = pat.as_ref() {
237                    path_param_types.push((&pi.ident, ty));
238                    call_args.push(quote!(#pat));
239                }
240            } else if !is_primitive_type(ty) {
241                has_body = true;
242                body_type = Some(ty);
243                call_args.push(quote!(#pat));
244            } else {
245                call_args.push(quote!(#pat));
246            }
247        }
248    }
249
250    let return_type = match &input_fn.sig.output {
251        syn::ReturnType::Type(_, ty) => Some(ty.as_ref()),
252        _ => None,
253    };
254
255    // Detect Result<T, ApiError> return type
256    let is_result_return = return_type
257        .map(|t| get_result_ok_type(t).is_some())
258        .unwrap_or(false);
259    let effective_return_type = return_type
260        .and_then(|t| get_result_ok_type(t))
261        .or(return_type);
262
263    let return_type_name = effective_return_type
264        .map(get_type_name)
265        .unwrap_or_else(|| "()".to_string());
266
267    // Detect Vec<T> return type for array schema (check effective type, i.e. inside Result if applicable)
268    let is_vec_response = effective_return_type
269        .map(|t| get_vec_inner_type_name(t).is_some())
270        .unwrap_or(false);
271    let vec_inner_type_name = effective_return_type
272        .and_then(get_vec_inner_type_name)
273        .unwrap_or_default();
274
275    let path_extraction = if !path_param_types.is_empty() {
276        let names: Vec<_> = path_param_types.iter().map(|(n, _)| *n).collect();
277        let types: Vec<_> = path_param_types.iter().map(|(_, t)| *t).collect();
278        if path_param_types.len() == 1 {
279            let n = names[0];
280            let t = types[0];
281            quote! {
282                let ultraapi::axum::extract::Path(#n): ultraapi::axum::extract::Path<#t> =
283                    ultraapi::axum::extract::Path::from_request_parts(&mut parts, &state).await
284                    .map_err(|e| ultraapi::ApiError::bad_request(format!("Invalid path param: {}", e)))?;
285            }
286        } else {
287            quote! {
288                let ultraapi::axum::extract::Path((#(#names),*)): ultraapi::axum::extract::Path<(#(#types),*)> =
289                    ultraapi::axum::extract::Path::from_request_parts(&mut parts, &state).await
290                    .map_err(|e| ultraapi::ApiError::bad_request(format!("Invalid path params: {}", e)))?;
291            }
292        }
293    } else {
294        quote! {}
295    };
296
297    let body_extraction = if has_body {
298        let bty = body_type.unwrap();
299        let bpat = input_fn
300            .sig
301            .inputs
302            .iter()
303            .find_map(|arg| {
304                if let FnArg::Typed(PatType { pat, ty, .. }) = arg {
305                    if !is_dep_type(ty) && !is_primitive_type(ty) && !is_query_type(ty) {
306                        let n = quote!(#pat).to_string();
307                        if !path_params.contains(&n) {
308                            return Some(pat.clone());
309                        }
310                    }
311                }
312                None
313            })
314            .unwrap();
315        quote! {
316            let ultraapi::axum::Json(#bpat): ultraapi::axum::Json<#bty> =
317                ultraapi::axum::Json::from_request(req, &state).await
318                .map_err(|e| ultraapi::ApiError::bad_request(format!("Invalid body: {}", e)))?;
319            #bpat.validate().map_err(|e| ultraapi::ApiError::validation_error(e))?;
320        }
321    } else {
322        quote! { let _ = req; }
323    };
324
325    // Generate response based on status code
326    let status_lit = proc_macro2::Literal::u16_unsuffixed(success_status);
327    let response_expr = if success_status == 204 {
328        if is_result_return {
329            quote! {
330                let _ = #fn_name(#(#call_args),*).await?;
331                Ok((ultraapi::axum::http::StatusCode::from_u16(#status_lit).unwrap(),).into_response())
332            }
333        } else {
334            quote! {
335                let _ = #fn_name(#(#call_args),*).await;
336                Ok((ultraapi::axum::http::StatusCode::from_u16(#status_lit).unwrap(),).into_response())
337            }
338        }
339    } else if is_result_return {
340        quote! {
341            let result = #fn_name(#(#call_args),*).await?;
342            let value = ultraapi::serde_json::to_value(&result)
343                .map_err(|e| ultraapi::ApiError::internal(format!("Response serialization failed: {}", e)))?;
344            Ok((
345                ultraapi::axum::http::StatusCode::from_u16(#status_lit).unwrap(),
346                ultraapi::axum::Json(value),
347            ).into_response())
348        }
349    } else {
350        quote! {
351            let result = #fn_name(#(#call_args),*).await;
352            let value = ultraapi::serde_json::to_value(&result)
353                .map_err(|e| ultraapi::ApiError::internal(format!("Response serialization failed: {}", e)))?;
354            Ok((
355                ultraapi::axum::http::StatusCode::from_u16(#status_lit).unwrap(),
356                ultraapi::axum::Json(value),
357            ).into_response())
358        }
359    };
360
361    let path_param_schemas: Vec<_> = path_params
362        .iter()
363        .map(|p| {
364            // Find the type of this path param
365            let openapi_type = path_param_types
366                .iter()
367                .find(|(name, _)| name.to_string() == *p)
368                .map(|(_, ty)| {
369                    let type_name = get_type_name(ty);
370                    match type_name.as_str() {
371                        "i8" | "i16" | "i32" | "i64" | "i128" | "u8" | "u16" | "u32" | "u64"
372                        | "u128" => "integer",
373                        "f32" | "f64" => "number",
374                        "String" => "string",
375                        "bool" => "boolean",
376                        _ => "string",
377                    }
378                })
379                .unwrap_or("string");
380            quote! {
381                ultraapi::openapi::Parameter {
382                    name: #p,
383                    location: "path",
384                    required: true,
385                    schema: ultraapi::openapi::SchemaObject::new_type(#openapi_type),
386                    description: None,
387                }
388            }
389        })
390        .collect();
391
392    let body_type_name = body_type.map(get_type_name).unwrap_or_default();
393    let fn_name_str = fn_name.to_string();
394
395    let query_params_fn_expr = if let Some(qt) = query_type {
396        quote! { Some(|| {
397            let root = ultraapi::schemars::schema_for!(#qt);
398            ultraapi::openapi::query_params_from_schema(&root)
399        }) }
400    } else {
401        quote! { None }
402    };
403
404    let output = quote! {
405        #(#clean_attrs)*
406        #fn_vis #fn_sig #fn_block
407
408        #[doc(hidden)]
409        async fn #wrapper_name(
410            ultraapi::axum::extract::State(state): ultraapi::axum::extract::State<ultraapi::AppState>,
411            mut parts: ultraapi::axum::http::request::Parts,
412            req: ultraapi::axum::http::Request<ultraapi::axum::body::Body>,
413        ) -> Result<ultraapi::axum::response::Response, ultraapi::ApiError> {
414            use ultraapi::axum::extract::FromRequest;
415            use ultraapi::axum::extract::FromRequestParts;
416            use ultraapi::axum::response::IntoResponse;
417            use ultraapi::Validate;
418
419            #path_extraction
420            #query_extraction
421            #(#dep_extractions)*
422            #body_extraction
423
424            #response_expr
425        }
426
427        #[doc(hidden)]
428        #[allow(non_upper_case_globals)]
429        static #route_info_name: ultraapi::RouteInfo = ultraapi::RouteInfo {
430            path: #path,
431            axum_path: #axum_path,
432            method: #method_upper,
433            handler_name: #fn_name_str,
434            response_type_name: #return_type_name,
435            is_result_return: #is_result_return,
436            is_vec_response: #is_vec_response,
437            vec_inner_type_name: #vec_inner_type_name,
438            parameters: &[#(#path_param_schemas),*],
439            has_body: #has_body,
440            body_type_name: #body_type_name,
441            success_status: #status_lit,
442            description: #description,
443            tags: &[#(#tags),*],
444            security: &[#(#security_schemes),*],
445            query_params_fn: #query_params_fn_expr,
446            register_fn: |app: ultraapi::axum::Router<ultraapi::AppState>| {
447                app.route(#axum_path, ultraapi::axum::routing::#method_ident(#wrapper_name))
448            },
449            method_router_fn: || {
450                ultraapi::axum::routing::#method_ident(#wrapper_name)
451            },
452        };
453
454        #[doc(hidden)]
455        pub static #route_ref_name: &ultraapi::RouteInfo = &#route_info_name;
456
457        // Backward compatibility alias (deprecated)
458        #[doc(hidden)]
459        #[allow(non_upper_case_globals)]
460        pub static #hayai_route_ref_name: &ultraapi::RouteInfo = &#route_info_name;
461
462        ultraapi::inventory::submit! { &#route_info_name }
463    };
464
465    output.into()
466}
467
468#[proc_macro_attribute]
469pub fn get(attr: TokenStream, item: TokenStream) -> TokenStream {
470    route_macro_impl("get", attr, item)
471}
472
473#[proc_macro_attribute]
474pub fn post(attr: TokenStream, item: TokenStream) -> TokenStream {
475    route_macro_impl("post", attr, item)
476}
477
478#[proc_macro_attribute]
479pub fn put(attr: TokenStream, item: TokenStream) -> TokenStream {
480    route_macro_impl("put", attr, item)
481}
482
483#[proc_macro_attribute]
484pub fn delete(attr: TokenStream, item: TokenStream) -> TokenStream {
485    route_macro_impl("delete", attr, item)
486}
487
488#[proc_macro_attribute]
489pub fn api_model(attr: TokenStream, item: TokenStream) -> TokenStream {
490    // Parse optional model-level custom validator: #[api_model(validate(custom = "my_fn"))]
491    let mut custom_validation_fn: Option<Path> = None;
492
493    let parser = syn::meta::parser(|meta| {
494        if meta.path.is_ident("validate") {
495            meta.parse_nested_meta(|nested| {
496                if nested.path.is_ident("custom") {
497                    let value = nested.value()?;
498                    let lit: LitStr = value.parse()?;
499                    let parsed_path = lit.parse::<Path>().map_err(|_| {
500                        nested.error("custom validator must be a valid path string")
501                    })?;
502                    custom_validation_fn = Some(parsed_path);
503                }
504                Ok(())
505            })?;
506        }
507        Ok(())
508    });
509
510    if let Err(err) = parser.parse(attr) {
511        return err.to_compile_error().into();
512    }
513
514    let item_clone = item.clone();
515    if let Ok(input) = syn::parse::<ItemStruct>(item) {
516        api_model_struct(input, custom_validation_fn)
517    } else if let Ok(input) = syn::parse::<ItemEnum>(item_clone) {
518        api_model_enum(input)
519    } else {
520        syn::Error::new(
521            proc_macro2::Span::call_site(),
522            "api_model only supports structs and enums",
523        )
524        .to_compile_error()
525        .into()
526    }
527}
528
529fn api_model_enum(input: ItemEnum) -> TokenStream {
530    let name = &input.ident;
531    let vis = &input.vis;
532    let attrs = &input.attrs;
533    let variants = &input.variants;
534    let description = extract_doc_comment(attrs);
535
536    let variant_names: Vec<String> = variants.iter().map(|v| v.ident.to_string()).collect();
537
538    let name_str = name.to_string();
539
540    let desc_expr = if description.is_empty() {
541        quote! { None }
542    } else {
543        quote! { Some(#description.to_string()) }
544    };
545
546    let output = quote! {
547        #(#attrs)*
548        #[derive(ultraapi::serde::Serialize, ultraapi::serde::Deserialize, ultraapi::schemars::JsonSchema)]
549        #[serde(crate = "ultraapi::serde")]
550        #[schemars(crate = "ultraapi::schemars")]
551        #vis enum #name {
552            #variants
553        }
554
555        impl ultraapi::Validate for #name {
556            fn validate(&self) -> Result<(), Vec<String>> { Ok(()) }
557        }
558
559        ultraapi::inventory::submit! {
560            ultraapi::SchemaInfo {
561                name: #name_str,
562                schema_fn: || {
563                    static CACHE: std::sync::OnceLock<ultraapi::openapi::Schema> = std::sync::OnceLock::new();
564                    CACHE.get_or_init(|| {
565                        ultraapi::openapi::Schema {
566                            type_name: "string".to_string(),
567                            properties: std::collections::HashMap::new(),
568                            required: vec![],
569                            description: #desc_expr,
570                            enum_values: Some(vec![#(#variant_names.to_string()),*]),
571                            example: None,
572                            one_of: None,
573                            discriminator: None,
574                        }
575                    }).clone()
576                },
577                nested_fn: || {
578                    static CACHE: std::sync::OnceLock<std::collections::HashMap<String, ultraapi::openapi::Schema>> = std::sync::OnceLock::new();
579                    CACHE.get_or_init(|| std::collections::HashMap::new()).clone()
580                },
581            }
582        }
583    };
584
585    output.into()
586}
587
588fn api_model_struct(input: ItemStruct, custom_validation_fn: Option<Path>) -> TokenStream {
589    let name = &input.ident;
590    let vis = &input.vis;
591    let attrs = &input.attrs;
592    let generics = &input.generics;
593    let struct_description = extract_doc_comment(attrs);
594
595    let fields = match &input.fields {
596        syn::Fields::Named(fields) => &fields.named,
597        _ => {
598            return syn::Error::new_spanned(
599                &input,
600                "api_model only supports structs with named fields",
601            )
602            .to_compile_error()
603            .into()
604        }
605    };
606
607    let mut validation_checks = Vec::new();
608    let mut schema_patches = Vec::new();
609    let mut clean_fields = Vec::new();
610
611    for field in fields {
612        let field_name = field.ident.as_ref().unwrap();
613        let field_name_str = field_name.to_string();
614
615        // Extract doc comment for field description
616        let field_desc = extract_doc_comment(&field.attrs);
617        if !field_desc.is_empty() {
618            schema_patches.push(quote! {
619                if let Some(prop) = props.get_mut(#field_name_str) {
620                    prop.description = Some(#field_desc.to_string());
621                }
622            });
623        }
624
625        for attr in &field.attrs {
626            if attr.path().is_ident("validate") {
627                let _ = attr.parse_nested_meta(|meta| {
628                    if meta.path.is_ident("min_length") {
629                        let value = meta.value()?;
630                        let lit: syn::LitInt = value.parse()?;
631                        let min: usize = lit.base10_parse()?;
632                        validation_checks.push(quote! {
633                            if self.#field_name.len() < #min {
634                                errors.push(format!("{}: must be at least {} characters", #field_name_str, #min));
635                            }
636                        });
637                        schema_patches.push(quote! {
638                            if let Some(prop) = props.get_mut(#field_name_str) {
639                                prop.min_length = Some(#min);
640                            }
641                        });
642                    } else if meta.path.is_ident("max_length") {
643                        let value = meta.value()?;
644                        let lit: syn::LitInt = value.parse()?;
645                        let max: usize = lit.base10_parse()?;
646                        validation_checks.push(quote! {
647                            if self.#field_name.len() > #max {
648                                errors.push(format!("{}: must be at most {} characters", #field_name_str, #max));
649                            }
650                        });
651                        schema_patches.push(quote! {
652                            if let Some(prop) = props.get_mut(#field_name_str) {
653                                prop.max_length = Some(#max);
654                            }
655                        });
656                    } else if meta.path.is_ident("email") {
657                        validation_checks.push(quote! {
658                            {
659                                let email = &self.#field_name;
660                                let at_count = email.chars().filter(|&c| c == '@').count();
661                                let valid = at_count == 1
662                                    && !email.starts_with('@')
663                                    && !email.ends_with('@')
664                                    && {
665                                        if let Some(at_pos) = email.find('@') {
666                                            let domain = &email[at_pos + 1..];
667                                            !domain.is_empty() && domain.contains('.')
668                                                && !domain.starts_with('.') && !domain.ends_with('.')
669                                        } else {
670                                            false
671                                        }
672                                    };
673                                if !valid {
674                                    errors.push(format!("{}: must be a valid email address", #field_name_str));
675                                }
676                            }
677                        });
678                        schema_patches.push(quote! {
679                            if let Some(prop) = props.get_mut(#field_name_str) {
680                                prop.format = Some("email".to_string());
681                            }
682                        });
683                    } else if meta.path.is_ident("minimum") {
684                        let value = meta.value()?;
685                        let lit: syn::LitInt = value.parse()?;
686                        let min: i64 = lit.base10_parse()?;
687                        let min_f64 = min as f64;
688                        validation_checks.push(quote! {
689                            if (self.#field_name as f64) < #min_f64 {
690                                errors.push(format!("{}: must be at least {}", #field_name_str, #min));
691                            }
692                        });
693                        schema_patches.push(quote! {
694                            if let Some(prop) = props.get_mut(#field_name_str) {
695                                prop.minimum = Some(#min_f64);
696                            }
697                        });
698                    } else if meta.path.is_ident("maximum") {
699                        let value = meta.value()?;
700                        let lit: syn::LitInt = value.parse()?;
701                        let max: i64 = lit.base10_parse()?;
702                        let max_f64 = max as f64;
703                        validation_checks.push(quote! {
704                            if (self.#field_name as f64) > #max_f64 {
705                                errors.push(format!("{}: must be at most {}", #field_name_str, #max));
706                            }
707                        });
708                        schema_patches.push(quote! {
709                            if let Some(prop) = props.get_mut(#field_name_str) {
710                                prop.maximum = Some(#max_f64);
711                            }
712                        });
713                    } else if meta.path.is_ident("pattern") {
714                        let value = meta.value()?;
715                        let lit: syn::LitStr = value.parse()?;
716                        let pat = lit.value();
717                        validation_checks.push(quote! {
718                            {
719                                static RE: std::sync::OnceLock<ultraapi::regex::Regex> = std::sync::OnceLock::new();
720                                let re = RE.get_or_init(|| ultraapi::regex::Regex::new(#pat).expect("Invalid regex"));
721                                if !re.is_match(&self.#field_name) {
722                                    errors.push(format!("{}: must match pattern {}", #field_name_str, #pat));
723                                }
724                            }
725                        });
726                        schema_patches.push(quote! {
727                            if let Some(prop) = props.get_mut(#field_name_str) {
728                                prop.pattern = Some(#pat.to_string());
729                            }
730                        });
731                    } else if meta.path.is_ident("min_items") {
732                        let value = meta.value()?;
733                        let lit: syn::LitInt = value.parse()?;
734                        let min: usize = lit.base10_parse()?;
735                        validation_checks.push(quote! {
736                            if self.#field_name.len() < #min {
737                                errors.push(format!("{}: must have at least {} items", #field_name_str, #min));
738                            }
739                        });
740                        schema_patches.push(quote! {
741                            if let Some(prop) = props.get_mut(#field_name_str) {
742                                prop.min_items = Some(#min);
743                            }
744                        });
745                    }
746                    Ok(())
747                });
748            } else if attr.path().is_ident("schema") {
749                let _ = attr.parse_nested_meta(|meta| {
750                    if meta.path.is_ident("example") {
751                        let value = meta.value()?;
752                        let lit: syn::LitStr = value.parse()?;
753                        let example_val = lit.value();
754                        schema_patches.push(quote! {
755                            if let Some(prop) = props.get_mut(#field_name_str) {
756                                prop.example = Some(#example_val.to_string());
757                            }
758                        });
759                    }
760                    Ok(())
761                });
762            }
763        }
764
765        let mut clean_field = field.clone();
766        clean_field
767            .attrs
768            .retain(|a| !a.path().is_ident("validate") && !a.path().is_ident("schema"));
769        clean_fields.push(clean_field);
770    }
771
772    let name_str = name.to_string();
773
774    let desc_expr = if struct_description.is_empty() {
775        quote! { None }
776    } else {
777        quote! { Some(#struct_description.to_string()) }
778    };
779
780    let custom_validation_check = if let Some(custom_fn) = custom_validation_fn {
781        quote! {
782            if let Err(custom_errors) = #custom_fn(self) {
783                errors.extend(custom_errors);
784            }
785        }
786    } else {
787        quote! {}
788    };
789
790    let output = quote! {
791        #(#attrs)*
792        #[derive(ultraapi::serde::Serialize, ultraapi::serde::Deserialize, ultraapi::schemars::JsonSchema)]
793        #[serde(crate = "ultraapi::serde")]
794        #[schemars(crate = "ultraapi::schemars")]
795        #vis struct #name #generics {
796            #(#clean_fields),*
797        }
798
799        impl ultraapi::Validate for #name {
800            fn validate(&self) -> Result<(), Vec<String>> {
801                let mut errors = Vec::new();
802                #(#validation_checks)*
803                #custom_validation_check
804                if errors.is_empty() { Ok(()) } else { Err(errors) }
805            }
806        }
807
808        impl ultraapi::HasSchemaPatches for #name {
809            fn patch_schema(props: &mut std::collections::HashMap<String, ultraapi::openapi::PropertyPatch>) {
810                #(#schema_patches)*
811            }
812        }
813
814        ultraapi::inventory::submit! {
815            ultraapi::SchemaInfo {
816                name: #name_str,
817                schema_fn: || {
818                    static CACHE: std::sync::OnceLock<ultraapi::openapi::Schema> = std::sync::OnceLock::new();
819                    CACHE.get_or_init(|| {
820                        let base = ultraapi::schemars::schema_for!(#name);
821                        let result = ultraapi::openapi::schema_from_schemars_full(#name_str, &base);
822                        let mut schema = result.schema;
823                        schema.description = #desc_expr;
824                        let mut patches = std::collections::HashMap::new();
825                        for (name, _) in &schema.properties {
826                            patches.insert(name.clone(), ultraapi::openapi::PropertyPatch::default());
827                        }
828                        <#name as ultraapi::HasSchemaPatches>::patch_schema(&mut patches);
829                        for (name, patch) in patches {
830                            if let Some(prop) = schema.properties.get_mut(&name) {
831                                if patch.min_length.is_some() { prop.min_length = patch.min_length; }
832                                if patch.max_length.is_some() { prop.max_length = patch.max_length; }
833                                if patch.format.is_some() { prop.format = patch.format; }
834                                if patch.minimum.is_some() { prop.minimum = patch.minimum; }
835                                if patch.maximum.is_some() { prop.maximum = patch.maximum; }
836                                if patch.pattern.is_some() { prop.pattern = patch.pattern.clone(); }
837                                if patch.min_items.is_some() { prop.min_items = patch.min_items; }
838                                if patch.description.is_some() { prop.description = patch.description.clone(); }
839                                if patch.example.is_some() { prop.example = patch.example.clone(); }
840                            }
841                        }
842                        schema
843                    }).clone()
844                },
845                nested_fn: || {
846                    static CACHE: std::sync::OnceLock<std::collections::HashMap<String, ultraapi::openapi::Schema>> = std::sync::OnceLock::new();
847                    CACHE.get_or_init(|| {
848                        let base = ultraapi::schemars::schema_for!(#name);
849                        let result = ultraapi::openapi::schema_from_schemars_full(#name_str, &base);
850                        result.nested
851                    }).clone()
852                },
853            }
854        }
855    };
856
857    output.into()
858}