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
53fn 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
69fn 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
103fn 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 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 clean_attrs.push(attr);
162 }
163 }
164
165 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(¶m_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 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 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 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 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 #[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 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 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}