Skip to main content

reinhardt_openapi_macros/
lib.rs

1//! Procedural macros for OpenAPI schema generation in Reinhardt.
2//!
3//! This crate provides derive macros and attribute macros for automatic
4//! OpenAPI schema generation from Rust types.
5//!
6
7#![warn(missing_docs)]
8
9use proc_macro::TokenStream;
10use quote::quote;
11use syn::{Data, DeriveInput, Fields, parse_macro_input};
12
13mod crate_paths;
14mod schema;
15mod serde_attrs;
16
17use crate::crate_paths::get_reinhardt_openapi_crate;
18use schema::{FieldAttributes, extract_container_attributes, extract_field_attributes};
19use serde_attrs::{
20	TaggingStrategy, extract_serde_enum_attrs, extract_serde_rename_all,
21	extract_serde_variant_attrs,
22};
23
24/// Derive macro for automatic OpenAPI schema generation.
25///
26/// This macro implements the `ToSchema` trait for your struct or enum,
27/// generating an OpenAPI schema based on the type's fields/variants and attributes.
28///
29/// # Attributes
30///
31/// ## Container Attributes
32///
33/// - `#[schema(title = "...")]` - Override the schema title (default: type name)
34/// - `#[schema(description = "...")]` - Schema description (overrides doc comments)
35/// - `#[schema(example = "...")]` - Example value for the entire type
36/// - `#[schema(deprecated)]` - Mark the entire type as deprecated
37/// - `#[schema(nullable)]` - Allow null values
38///
39/// ## Field Attributes (for structs)
40///
41/// - `#[schema(description = "...")]` - Field description (also reads doc comments)
42/// - `#[schema(example = "...")]` - Example value for this field
43/// - `#[schema(default)]` - Mark field as having a default value
44/// - `#[schema(deprecated)]` - Mark field as deprecated
45/// - `#[schema(read_only)]` - Field is read-only (GET responses only)
46/// - `#[schema(write_only)]` - Field is write-only (POST/PUT requests only)
47/// - `#[schema(format = "...")]` - OpenAPI format (e.g., "email", "uri", "date-time")
48/// - `#[schema(minimum = N)]` - Minimum value for numbers (inclusive)
49/// - `#[schema(maximum = N)]` - Maximum value for numbers (inclusive)
50/// - `#[schema(exclusive_minimum = N)]` - Minimum value for numbers (exclusive)
51/// - `#[schema(exclusive_maximum = N)]` - Maximum value for numbers (exclusive)
52/// - `#[schema(multiple_of = N)]` - Value must be a multiple of N
53/// - `#[schema(min_length = N)]` - Minimum length for strings
54/// - `#[schema(max_length = N)]` - Maximum length for strings
55/// - `#[schema(pattern = "...")]` - Regex pattern for string validation
56/// - `#[schema(min_items = N)]` - Minimum number of array items
57/// - `#[schema(max_items = N)]` - Maximum number of array items
58/// - `#[schema(unique_items)]` - Array items must be unique
59/// - `#[schema(nullable)]` - Field allows null values
60/// - `#[schema(default_value = "...")]` - Default value (JSON string)
61/// - `#[schema(title = "...")]` - Field-level title override
62///
63/// # Enum Support
64///
65/// The macro supports serde's enum tagging strategies:
66///
67/// - **External** (default): `{"VariantName": {...}}`
68/// - **Internal**: `#[serde(tag = "type")]` -> `{"type": "VariantName", ...}`
69/// - **Adjacent**: `#[serde(tag = "t", content = "c")]` -> `{"t": "VariantName", "c": {...}}`
70/// - **Untagged**: `#[serde(untagged)]` -> `{...}` (no discriminator)
71///
72/// ## Variant Types
73///
74/// - **Unit variants**: Become string enum values
75/// - **Newtype variants**: Use the inner type's schema
76/// - **Tuple variants**: Generate array schema
77/// - **Struct variants**: Generate object schema with properties
78///
79#[proc_macro_derive(Schema, attributes(schema))]
80pub fn derive_schema(input: TokenStream) -> TokenStream {
81	let input = parse_macro_input!(input as DeriveInput);
82
83	match &input.data {
84		Data::Struct(data) => derive_struct_schema(&input, data),
85		Data::Enum(data) => derive_enum_schema(&input, data),
86		Data::Union(_) => syn::Error::new_spanned(&input, "Schema cannot be derived for unions")
87			.to_compile_error()
88			.into(),
89	}
90}
91
92/// Generate schema for struct types
93fn derive_struct_schema(input: &DeriveInput, data: &syn::DataStruct) -> TokenStream {
94	let name = &input.ident;
95	let generics = &input.generics;
96	let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
97
98	let fields = match &data.fields {
99		Fields::Named(fields) => &fields.named,
100		_ => {
101			return syn::Error::new_spanned(
102				input,
103				"Schema can only be derived for structs with named fields",
104			)
105			.to_compile_error()
106			.into();
107		}
108	};
109
110	// Extract container-level attributes
111	let struct_name = name.to_string();
112	let container_attrs = match extract_container_attributes(&input.attrs) {
113		Ok(attrs) => attrs,
114		Err(err) => return err.to_compile_error().into(),
115	};
116
117	// Extract container-level rename_all for field name transformation
118	// Fixes #835
119	let rename_all = extract_serde_rename_all(&input.attrs);
120
121	// Generate schema for each field
122	let mut field_schemas = Vec::new();
123	let mut required_fields = Vec::new();
124	// Fixes #839: Track flattened fields for allOf generation
125	let mut flatten_schemas = Vec::new();
126
127	for field in fields {
128		let field_name = field.ident.as_ref().unwrap();
129		let field_name_str = field_name.to_string();
130		let field_type = &field.ty;
131
132		// Extract field attributes
133		let attrs = match extract_field_attributes(&field.attrs) {
134			Ok(attrs) => attrs,
135			Err(err) => return err.to_compile_error().into(),
136		};
137
138		// Fixes #837: Validate mutual exclusion of read_only and write_only
139		if attrs.read_only && attrs.write_only {
140			return syn::Error::new_spanned(
141				field,
142				"A field cannot be both read_only and write_only",
143			)
144			.to_compile_error()
145			.into();
146		}
147
148		// Fixes #836: Skip fields with serde skip attributes
149		if attrs.skip || attrs.skip_serializing || attrs.skip_deserializing {
150			continue;
151		}
152
153		// Fixes #839: Handle flattened fields separately
154		if attrs.flatten {
155			let schema_builder = build_field_schema(field_type, &attrs);
156			flatten_schemas.push(schema_builder);
157			continue;
158		}
159
160		// Use renamed property name if available (from #[serde(rename)] or #[schema(rename)])
161		// Fixes #835: Apply rename_all if no explicit rename is set
162		let property_name = attrs
163			.rename
164			.clone()
165			.unwrap_or_else(|| apply_rename_all(&field_name_str, rename_all.as_deref()));
166
167		// Check if field is Option<T> (makes it optional)
168		// Fixes #838: Also consider default attribute for required fields
169		let is_option = is_option_type(field_type);
170		if !is_option && !attrs.default {
171			required_fields.push(property_name.clone());
172		}
173
174		// Build field schema with attributes
175		let schema_builder = build_field_schema(field_type, &attrs);
176
177		field_schemas.push(quote! {
178			builder = builder.property(#property_name, #schema_builder);
179		});
180	}
181
182	// Add required fields
183	let required_builder = if !required_fields.is_empty() {
184		quote! {
185			#(builder = builder.required(#required_fields);)*
186		}
187	} else {
188		quote! {}
189	};
190
191	// Get dynamic crate path
192	let openapi_crate = get_reinhardt_openapi_crate();
193
194	// Generate container attribute modifications
195	let container_mods = generate_container_modifications(&container_attrs, &openapi_crate);
196
197	// Fixes #839: Generate allOf if there are flattened fields
198	let schema_body = if !flatten_schemas.is_empty() {
199		quote! {
200			use #openapi_crate::Schema;
201			use #openapi_crate::utoipa::openapi::schema::{AllOfBuilder, ObjectBuilder, SchemaType, Type};
202
203			// Build the main object schema with regular properties
204			let mut builder = ObjectBuilder::new()
205				.schema_type(SchemaType::Type(Type::Object));
206
207			#(#field_schemas)*
208			#required_builder
209
210			let main_schema = Schema::Object(builder.build());
211
212			// Combine with flattened schemas using allOf
213			let mut all_of_builder = AllOfBuilder::new();
214			all_of_builder = all_of_builder.item(#openapi_crate::RefOr::T(main_schema));
215			#(all_of_builder = all_of_builder.item(#openapi_crate::RefOr::T(#flatten_schemas));)*
216
217			let mut schema = Schema::AllOf(all_of_builder.build());
218			#container_mods
219			schema
220		}
221	} else {
222		quote! {
223			use #openapi_crate::Schema;
224			use #openapi_crate::utoipa::openapi::schema::{ObjectBuilder, SchemaType, Type};
225
226			let mut builder = ObjectBuilder::new()
227				.schema_type(SchemaType::Type(Type::Object));
228
229			#(#field_schemas)*
230			#required_builder
231
232			let mut schema = Schema::Object(builder.build());
233			#container_mods
234			schema
235		}
236	};
237
238	// Generate inventory registration only for non-generic types
239	// Generic types cannot be registered at compile time since they don't have a concrete type
240	let inventory_registration = if generics.params.is_empty() {
241		quote! {
242			// Automatic schema registration via inventory
243			// This allows the framework to discover all schemas at compile time
244			::inventory::submit! {
245				#openapi_crate::SchemaRegistration::new(
246					#struct_name,
247					#name::schema
248				)
249			}
250		}
251	} else {
252		quote! {}
253	};
254
255	let expanded = quote! {
256		impl #impl_generics #openapi_crate::ToSchema for #name #ty_generics #where_clause {
257			fn schema() -> #openapi_crate::Schema {
258				#schema_body
259			}
260
261			fn schema_name() -> Option<String> {
262				Some(#struct_name.to_string())
263			}
264		}
265
266		#inventory_registration
267	};
268
269	TokenStream::from(expanded)
270}
271
272/// Generate schema for enum types
273fn derive_enum_schema(input: &DeriveInput, data: &syn::DataEnum) -> TokenStream {
274	let name = &input.ident;
275	let generics = &input.generics;
276	let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
277	let enum_name = name.to_string();
278
279	// Extract container-level attributes
280	let container_attrs = match extract_container_attributes(&input.attrs) {
281		Ok(attrs) => attrs,
282		Err(err) => return err.to_compile_error().into(),
283	};
284
285	// Extract serde enum attributes for tagging strategy
286	let serde_attrs = extract_serde_enum_attrs(&input.attrs);
287	let tagging = serde_attrs.tagging_strategy();
288
289	// Get dynamic crate path
290	let openapi_crate = get_reinhardt_openapi_crate();
291
292	// Check if all variants are unit variants (simple string enum)
293	let all_unit_variants = data
294		.variants
295		.iter()
296		.all(|v| matches!(v.fields, Fields::Unit));
297
298	// Generate container attribute modifications
299	let container_mods = generate_container_modifications(&container_attrs, &openapi_crate);
300
301	let schema_body = if all_unit_variants && matches!(tagging, TaggingStrategy::External) {
302		// Simple string enum: generate string schema with enum values
303		let base = generate_simple_enum_schema(data, &openapi_crate, &serde_attrs);
304		quote! {
305			let mut schema = { #base };
306			#container_mods
307			schema
308		}
309	} else {
310		// Complex enum: use EnumSchemaBuilder
311		let base = generate_complex_enum_schema(data, &openapi_crate, &enum_name, &tagging);
312		quote! {
313			let mut schema = { #base };
314			#container_mods
315			schema
316		}
317	};
318
319	// Generate inventory registration only for non-generic types
320	let inventory_registration = if generics.params.is_empty() {
321		quote! {
322			::inventory::submit! {
323				#openapi_crate::SchemaRegistration::new(
324					#enum_name,
325					#name::schema
326				)
327			}
328		}
329	} else {
330		quote! {}
331	};
332
333	let expanded = quote! {
334		impl #impl_generics #openapi_crate::ToSchema for #name #ty_generics #where_clause {
335			fn schema() -> #openapi_crate::Schema {
336				#schema_body
337			}
338
339			fn schema_name() -> Option<String> {
340				Some(#enum_name.to_string())
341			}
342		}
343
344		#inventory_registration
345	};
346
347	TokenStream::from(expanded)
348}
349
350/// Generate code to apply container-level attributes to a schema
351///
352/// Handles both `Schema::Object` and `Schema::AllOf` variants, so container
353/// attributes work correctly for flattened structs that produce AllOf schemas.
354fn generate_container_modifications(
355	attrs: &schema::ContainerAttributes,
356	openapi_crate: &proc_macro2::TokenStream,
357) -> proc_macro2::TokenStream {
358	let mut mods = Vec::new();
359
360	if let Some(ref title) = attrs.title {
361		mods.push(quote! {
362			match schema {
363				Schema::Object(ref mut obj) => {
364					obj.title = Some(#title.to_string());
365				}
366				Schema::AllOf(ref mut all_of) => {
367					all_of.title = Some(#title.to_string());
368				}
369				_ => {}
370			}
371		});
372	}
373
374	if let Some(ref description) = attrs.description {
375		mods.push(quote! {
376			match schema {
377				Schema::Object(ref mut obj) => {
378					obj.description = Some(#description.to_string());
379				}
380				Schema::AllOf(ref mut all_of) => {
381					all_of.description = Some(#description.to_string());
382				}
383				_ => {}
384			}
385		});
386	}
387
388	if let Some(ref example) = attrs.example {
389		// Parse the example string as JSON; fall back to a JSON string if invalid
390		mods.push(quote! {
391			let example_value: serde_json::Value = serde_json::from_str(#example)
392				.unwrap_or_else(|_| serde_json::json!(#example));
393			match schema {
394				Schema::Object(ref mut obj) => {
395					obj.example = Some(example_value);
396				}
397				Schema::AllOf(ref mut all_of) => {
398					all_of.example = Some(example_value);
399				}
400				_ => {}
401			}
402		});
403	}
404
405	if attrs.deprecated {
406		mods.push(quote! {
407			match schema {
408				Schema::Object(ref mut obj) => {
409					obj.deprecated = Some(#openapi_crate::utoipa::openapi::Deprecated::True);
410				}
411				// AllOf does not have a deprecated field in utoipa;
412				// wrap in a single-item AllOf with deprecated on the inner object
413				_ => {}
414			}
415		});
416	}
417
418	if attrs.nullable {
419		mods.push(quote! {
420			{
421				use #openapi_crate::utoipa::openapi::schema::{SchemaType, Type};
422				match schema {
423					Schema::Object(ref mut obj) => {
424						// Preserve the existing type and add Null
425						let existing_type = std::mem::replace(
426							&mut obj.schema_type,
427							SchemaType::AnyValue,
428						);
429						match existing_type {
430							SchemaType::Type(t) => {
431								obj.schema_type = SchemaType::from_iter([t, Type::Null]);
432							}
433							other => {
434								obj.schema_type = other;
435							}
436						}
437					}
438					Schema::AllOf(ref mut all_of) => {
439						// AllOf nullable: set schema_type to include Null
440						let existing_type = std::mem::replace(
441							&mut all_of.schema_type,
442							SchemaType::AnyValue,
443						);
444						match existing_type {
445							SchemaType::AnyValue => {
446								all_of.schema_type = SchemaType::from_iter([Type::Object, Type::Null]);
447							}
448							SchemaType::Type(t) => {
449								all_of.schema_type = SchemaType::from_iter([t, Type::Null]);
450							}
451							other => {
452								all_of.schema_type = other;
453							}
454						}
455					}
456					_ => {}
457				}
458			}
459		});
460	}
461
462	if mods.is_empty() {
463		quote! {}
464	} else {
465		quote! {
466			#(#mods)*
467		}
468	}
469}
470
471/// Generate schema for simple unit-variant enums (string enum)
472fn generate_simple_enum_schema(
473	data: &syn::DataEnum,
474	openapi_crate: &proc_macro2::TokenStream,
475	serde_attrs: &serde_attrs::SerdeEnumAttrs,
476) -> proc_macro2::TokenStream {
477	let variant_names: Vec<String> = data
478		.variants
479		.iter()
480		.filter_map(|v| {
481			let variant_attrs = extract_serde_variant_attrs(&v.attrs);
482			if variant_attrs.skip {
483				return None;
484			}
485			// Apply rename if present, considering rename_all strategy
486			let name = variant_attrs.rename.unwrap_or_else(|| {
487				apply_rename_all(&v.ident.to_string(), serde_attrs.rename_all.as_deref())
488			});
489			Some(name)
490		})
491		.collect();
492
493	quote! {
494		use #openapi_crate::Schema;
495		use #openapi_crate::utoipa::openapi::schema::{ObjectBuilder, SchemaType, Type};
496
497		Schema::Object(
498			ObjectBuilder::new()
499				.schema_type(SchemaType::Type(Type::String))
500				.enum_values(Some(vec![#(serde_json::Value::String(#variant_names.to_string())),*]))
501				.build()
502		)
503	}
504}
505
506/// Generate schema for complex enums using EnumSchemaBuilder
507fn generate_complex_enum_schema(
508	data: &syn::DataEnum,
509	openapi_crate: &proc_macro2::TokenStream,
510	enum_name: &str,
511	tagging: &TaggingStrategy,
512) -> proc_macro2::TokenStream {
513	// Generate tagging strategy expression
514	let tagging_expr = match tagging {
515		TaggingStrategy::External => quote! {
516			#openapi_crate::EnumTagging::External
517		},
518		TaggingStrategy::Internal { tag } => quote! {
519			#openapi_crate::EnumTagging::Internal { tag: #tag.to_string() }
520		},
521		TaggingStrategy::Adjacent { tag, content } => quote! {
522			#openapi_crate::EnumTagging::Adjacent {
523				tag: #tag.to_string(),
524				content: #content.to_string(),
525			}
526		},
527		TaggingStrategy::Untagged => quote! {
528			#openapi_crate::EnumTagging::Untagged
529		},
530	};
531
532	// Generate variant schemas
533	let variant_additions: Vec<proc_macro2::TokenStream> = data
534		.variants
535		.iter()
536		.filter_map(|variant| {
537			let variant_attrs = extract_serde_variant_attrs(&variant.attrs);
538			if variant_attrs.skip {
539				return None;
540			}
541
542			let variant_name = variant_attrs
543				.rename
544				.clone()
545				.unwrap_or_else(|| variant.ident.to_string());
546
547			let variant_schema = generate_variant_schema(&variant.fields, openapi_crate);
548
549			Some(quote! {
550				builder = builder.variant(#variant_name, #variant_schema);
551			})
552		})
553		.collect();
554
555	quote! {
556		use #openapi_crate::{EnumSchemaBuilder, Schema, SchemaExt};
557
558		let mut builder = EnumSchemaBuilder::new(#enum_name)
559			.tagging(#tagging_expr);
560
561		#(#variant_additions)*
562
563		builder.build()
564	}
565}
566
567/// Generate schema for a single variant's fields
568fn generate_variant_schema(
569	fields: &Fields,
570	openapi_crate: &proc_macro2::TokenStream,
571) -> proc_macro2::TokenStream {
572	match fields {
573		Fields::Unit => {
574			// Unit variant: empty object or null
575			quote! {
576				#openapi_crate::Schema::object()
577			}
578		}
579		Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
580			// Newtype variant: use inner type's schema
581			let inner_type = &fields.unnamed.first().unwrap().ty;
582			quote! {
583				<#inner_type as #openapi_crate::ToSchema>::schema()
584			}
585		}
586		Fields::Unnamed(fields) => {
587			// Tuple variant: array of inner types
588			let type_schemas: Vec<proc_macro2::TokenStream> = fields
589				.unnamed
590				.iter()
591				.map(|f| {
592					let ty = &f.ty;
593					quote! {
594						#openapi_crate::RefOr::T(<#ty as #openapi_crate::ToSchema>::schema())
595					}
596				})
597				.collect();
598
599			quote! {
600				{
601					use #openapi_crate::utoipa::openapi::schema::{ArrayBuilder, SchemaType, Type};
602					#openapi_crate::Schema::Array(
603						ArrayBuilder::new()
604							.schema_type(SchemaType::Type(Type::Array))
605							.prefix_items(vec![#(#type_schemas),*])
606							.build()
607					)
608				}
609			}
610		}
611		Fields::Named(fields) => {
612			// Struct variant: object with properties
613			let mut property_additions = Vec::new();
614			let mut required_additions = Vec::new();
615
616			for field in &fields.named {
617				let field_name = field.ident.as_ref().unwrap();
618				let field_name_str = field_name.to_string();
619				let field_type = &field.ty;
620
621				// Check for serde rename on field
622				let field_attrs = extract_field_attributes(&field.attrs).unwrap_or_default();
623				let property_name = field_attrs.rename.unwrap_or(field_name_str);
624
625				// Check if required
626				let is_option = is_option_type(field_type);
627				if !is_option {
628					required_additions.push(quote! {
629						builder = builder.required(#property_name);
630					});
631				}
632
633				property_additions.push(quote! {
634					builder = builder.property(
635						#property_name,
636						<#field_type as #openapi_crate::ToSchema>::schema()
637					);
638				});
639			}
640
641			quote! {
642				{
643					use #openapi_crate::utoipa::openapi::schema::{ObjectBuilder, SchemaType, Type};
644					let mut builder = ObjectBuilder::new()
645						.schema_type(SchemaType::Type(Type::Object));
646					#(#property_additions)*
647					#(#required_additions)*
648					#openapi_crate::Schema::Object(builder.build())
649				}
650			}
651		}
652	}
653}
654
655/// Apply rename_all transformation to a variant name
656fn apply_rename_all(name: &str, rename_all: Option<&str>) -> String {
657	match rename_all {
658		Some("lowercase") => name.to_lowercase(),
659		Some("UPPERCASE") => name.to_uppercase(),
660		Some("camelCase") => to_camel_case(name),
661		Some("snake_case") => to_snake_case(name),
662		Some("SCREAMING_SNAKE_CASE") => to_snake_case(name).to_uppercase(),
663		Some("kebab-case") => to_snake_case(name).replace('_', "-"),
664		Some("SCREAMING-KEBAB-CASE") => to_snake_case(name).to_uppercase().replace('_', "-"),
665		Some("PascalCase") | None => name.to_string(),
666		Some(_) => name.to_string(),
667	}
668}
669
670/// Convert PascalCase to camelCase
671fn to_camel_case(s: &str) -> String {
672	let mut result = String::new();
673	for (i, c) in s.chars().enumerate() {
674		if i == 0 {
675			result.extend(c.to_lowercase());
676		} else {
677			result.push(c);
678		}
679	}
680	result
681}
682
683/// Convert PascalCase to snake_case
684/// Fixes #833: Handle consecutive uppercase correctly (e.g., "XMLParser" -> "xmlparser")
685///
686/// This follows serde's behavior where consecutive uppercase letters are treated
687/// as a single word (e.g., "XMLParser" -> "xmlparser", not "x_m_l_parser").
688fn to_snake_case(s: &str) -> String {
689	let mut result = String::new();
690	let chars: Vec<char> = s.chars().collect();
691
692	for (i, c) in chars.iter().enumerate() {
693		if c.is_uppercase() {
694			// Only insert underscore before uppercase if:
695			// 1. Not the first character AND
696			// 2. Previous character is lowercase OR
697			// 3. (Not the last character AND next character is lowercase)
698			// This handles: "HttpRequest" -> "http_request"
699			// But not: "XMLParser" -> "x_m_l_parser" (becomes "xmlparser")
700			if i > 0 {
701				let prev_is_lowercase = chars[i - 1].is_lowercase();
702				let next_is_lowercase = i + 1 < chars.len() && chars[i + 1].is_lowercase();
703
704				if prev_is_lowercase || next_is_lowercase {
705					result.push('_');
706				}
707			}
708			result.extend(c.to_lowercase());
709		} else {
710			result.push(*c);
711		}
712	}
713	result
714}
715
716/// Helper function to check if a type is `Option<T>`
717fn is_option_type(ty: &syn::Type) -> bool {
718	if let syn::Type::Path(type_path) = ty
719		&& let Some(segment) = type_path.path.segments.last()
720	{
721		return segment.ident == "Option";
722	}
723	false
724}
725
726/// Build schema for a field type with attributes
727fn build_field_schema(field_type: &syn::Type, attrs: &FieldAttributes) -> proc_macro2::TokenStream {
728	let openapi_crate = get_reinhardt_openapi_crate();
729	let base_schema = quote! {
730		<#field_type as #openapi_crate::ToSchema>::schema()
731	};
732
733	// If no attributes, return base schema
734	if attrs.is_empty() {
735		return base_schema;
736	}
737
738	// Build schema with attributes applied
739	let mut modifications = Vec::new();
740
741	if let Some(ref title) = attrs.title {
742		modifications.push(quote! {
743			if let Schema::Object(ref mut obj) = schema {
744				obj.title = Some(#title.to_string());
745			}
746		});
747	}
748
749	if let Some(ref description) = attrs.description {
750		modifications.push(quote! {
751			if let Schema::Object(ref mut obj) = schema {
752				obj.description = Some(#description.to_string());
753			}
754		});
755	}
756
757	if let Some(ref example) = attrs.example {
758		// Parse the example string as JSON; fall back to a JSON string if invalid
759		modifications.push(quote! {
760			if let Schema::Object(ref mut obj) = schema {
761				obj.example = Some(
762					serde_json::from_str(#example)
763						.unwrap_or_else(|_| serde_json::json!(#example))
764				);
765			}
766		});
767	}
768
769	if let Some(ref format) = attrs.format {
770		modifications.push(quote! {
771			if let Schema::Object(ref mut obj) = schema {
772				obj.format = Some(#openapi_crate::utoipa::openapi::schema::SchemaFormat::Custom(#format.to_string()));
773			}
774		});
775	}
776
777	if attrs.read_only {
778		modifications.push(quote! {
779			if let Schema::Object(ref mut obj) = schema {
780				obj.read_only = Some(true);
781			}
782		});
783	}
784
785	if attrs.write_only {
786		modifications.push(quote! {
787			if let Schema::Object(ref mut obj) = schema {
788				obj.write_only = Some(true);
789			}
790		});
791	}
792
793	if attrs.deprecated {
794		modifications.push(quote! {
795			if let Schema::Object(ref mut obj) = schema {
796				obj.deprecated = Some(#openapi_crate::utoipa::openapi::Deprecated::True);
797			}
798		});
799	}
800
801	if let Some(min) = attrs.minimum {
802		modifications.push(quote! {
803			if let Schema::Object(ref mut obj) = schema {
804				obj.minimum = Some(#openapi_crate::utoipa::Number::from(#min as f64));
805			}
806		});
807	}
808
809	if let Some(max) = attrs.maximum {
810		modifications.push(quote! {
811			if let Schema::Object(ref mut obj) = schema {
812				obj.maximum = Some(#openapi_crate::utoipa::Number::from(#max as f64));
813			}
814		});
815	}
816
817	if let Some(ex_min) = attrs.exclusive_minimum {
818		modifications.push(quote! {
819			if let Schema::Object(ref mut obj) = schema {
820				obj.exclusive_minimum = Some(#openapi_crate::utoipa::Number::from(#ex_min as f64));
821			}
822		});
823	}
824
825	if let Some(ex_max) = attrs.exclusive_maximum {
826		modifications.push(quote! {
827			if let Schema::Object(ref mut obj) = schema {
828				obj.exclusive_maximum = Some(#openapi_crate::utoipa::Number::from(#ex_max as f64));
829			}
830		});
831	}
832
833	if let Some(mul) = attrs.multiple_of {
834		modifications.push(quote! {
835			if let Schema::Object(ref mut obj) = schema {
836				obj.multiple_of = Some(#openapi_crate::utoipa::Number::from(#mul));
837			}
838		});
839	}
840
841	if let Some(min_len) = attrs.min_length {
842		modifications.push(quote! {
843			if let Schema::Object(ref mut obj) = schema {
844				obj.min_length = Some(#min_len);
845			}
846		});
847	}
848
849	if let Some(max_len) = attrs.max_length {
850		modifications.push(quote! {
851			if let Schema::Object(ref mut obj) = schema {
852				obj.max_length = Some(#max_len);
853			}
854		});
855	}
856
857	if let Some(ref pattern) = attrs.pattern {
858		modifications.push(quote! {
859			if let Schema::Object(ref mut obj) = schema {
860				obj.pattern = Some(#pattern.to_string());
861			}
862		});
863	}
864
865	if let Some(min_items) = attrs.min_items {
866		modifications.push(quote! {
867			if let Schema::Array(ref mut arr) = schema {
868				arr.min_items = Some(#min_items);
869			}
870		});
871	}
872
873	if let Some(max_items) = attrs.max_items {
874		modifications.push(quote! {
875			if let Schema::Array(ref mut arr) = schema {
876				arr.max_items = Some(#max_items);
877			}
878		});
879	}
880
881	if attrs.unique_items {
882		modifications.push(quote! {
883			if let Schema::Array(ref mut arr) = schema {
884				arr.unique_items = true;
885			}
886		});
887	}
888
889	if attrs.nullable {
890		modifications.push(quote! {
891			use #openapi_crate::utoipa::openapi::schema::{SchemaType, Type};
892			match schema {
893				Schema::Object(ref mut obj) => {
894					// Preserve the existing type and add Null
895					let existing_type = std::mem::replace(
896						&mut obj.schema_type,
897						SchemaType::AnyValue,
898					);
899					match existing_type {
900						SchemaType::Type(t) => {
901							obj.schema_type = SchemaType::from_iter([t, Type::Null]);
902						}
903						other => {
904							obj.schema_type = other;
905						}
906					}
907				}
908				Schema::Array(ref mut arr) => {
909					// Preserve the existing array type and add Null
910					let existing_type = std::mem::replace(
911						&mut arr.schema_type,
912						SchemaType::AnyValue,
913					);
914					match existing_type {
915						SchemaType::Type(t) => {
916							arr.schema_type = SchemaType::from_iter([t, Type::Null]);
917						}
918						other => {
919							arr.schema_type = other;
920						}
921					}
922				}
923				Schema::AllOf(ref mut all_of) => {
924					// AllOf nullable: set schema_type to include Null
925					let existing_type = std::mem::replace(
926						&mut all_of.schema_type,
927						SchemaType::AnyValue,
928					);
929					match existing_type {
930						SchemaType::AnyValue => {
931							all_of.schema_type = SchemaType::from_iter([Type::Object, Type::Null]);
932						}
933						SchemaType::Type(t) => {
934							all_of.schema_type = SchemaType::from_iter([t, Type::Null]);
935						}
936						other => {
937							all_of.schema_type = other;
938						}
939					}
940				}
941				_ => {}
942			}
943		});
944	}
945
946	if let Some(ref default_value) = attrs.default_value {
947		modifications.push(quote! {
948			if let Schema::Object(ref mut obj) = schema {
949				obj.default = Some(serde_json::json!(#default_value));
950			}
951		});
952	}
953
954	if modifications.is_empty() {
955		base_schema
956	} else {
957		quote! {
958			{
959				use #openapi_crate::Schema;
960				let mut schema = #base_schema;
961				#(#modifications)*
962				schema
963			}
964		}
965	}
966}