oxapi_macro/lib.rs
1//! Procedural macros for the oxapi OpenAPI server stub generator.
2//!
3//! This crate provides the `#[oxapi]` attribute macro for generating type-safe
4//! server stubs from OpenAPI specifications. See [`oxapi`] for full documentation.
5
6use proc_macro::TokenStream;
7use proc_macro2::TokenStream as TokenStream2;
8use quote::{ToTokens, quote, quote_spanned};
9use schemars::schema::{InstanceType, SchemaObject, SingleOrVec};
10use syn::{Ident, ItemMod, ItemTrait, LitStr, Token};
11
12/// Generate type-safe server stubs from OpenAPI specifications.
13///
14/// # Syntax
15///
16/// ```text
17/// #[oxapi(framework, "spec_path")]
18/// #[oxapi(framework, "spec_path", unwrap)]
19/// #[oxapi(framework, "spec_path", ok_suffix = "Response", err_suffix = "Error")]
20/// #[oxapi(framework, "spec_path", derive = (Debug, Clone))]
21/// #[oxapi(framework, "spec_path", unwrap, ok_suffix = "Ok", err_suffix = "Err")]
22/// ```
23///
24/// ## Arguments
25///
26/// - `framework`: The web framework to generate code for. Currently only `axum` is supported.
27/// - `spec_path`: Path to the OpenAPI specification file (JSON or YAML), relative to `Cargo.toml`.
28/// - `unwrap` (optional): Emit contents directly without wrapping in a module.
29/// - `ok_suffix` (optional): Suffix for success response types. Defaults to `"Response"`.
30/// - `err_suffix` (optional): Suffix for error response types. Defaults to `"Error"`.
31/// - `derive` (optional): Default derives for generated response enums. Defaults to `(Debug)`.
32///
33/// All options after the spec path can be specified in any order.
34///
35/// # Usage Modes
36///
37/// ## Single Trait Mode
38///
39/// Apply directly to a trait to generate types in a sibling `{trait_name}_types` module:
40///
41/// ```ignore
42/// #[oxapi::oxapi(axum, "spec.json")]
43/// trait PetService<S> {
44/// #[oxapi(map)]
45/// fn map_routes(router: Router<S>) -> Router<S>;
46///
47/// #[oxapi(get, "/pet/{petId}")]
48/// async fn get_pet(state: State<S>, pet_id: Path<_>) -> Result<GetPetResponse, GetPetError>;
49/// }
50/// ```
51///
52/// ## Module Mode
53///
54/// Apply to a module containing one or more traits. Types are generated in a shared `types` submodule:
55///
56/// ```ignore
57/// #[oxapi::oxapi(axum, "spec.json")]
58/// mod api {
59/// trait PetService<S> {
60/// #[oxapi(map)]
61/// fn map_routes(router: Router<S>) -> Router<S>;
62///
63/// #[oxapi(get, "/pet/{petId}")]
64/// async fn get_pet(state: State<S>, pet_id: Path<_>);
65/// }
66///
67/// trait StoreService<S> {
68/// #[oxapi(map)]
69/// fn map_routes(router: Router<S>) -> Router<S>;
70///
71/// #[oxapi(get, "/store/inventory")]
72/// async fn get_inventory(state: State<S>);
73/// }
74/// }
75/// ```
76///
77/// ## Unwrap Mode
78///
79/// Use `unwrap` to emit contents directly without a module wrapper:
80///
81/// ```ignore
82/// #[oxapi::oxapi(axum, "spec.json", unwrap)]
83/// mod api {
84/// trait PetService<S> { /* ... */ }
85/// }
86/// // Generates: pub mod types { ... } pub trait PetService<S> { ... }
87/// // Instead of: mod api { pub mod types { ... } pub trait PetService<S> { ... } }
88/// ```
89///
90/// # Method Attributes
91///
92/// ## `#[oxapi(map)]`
93///
94/// Marks a method as the route mapper. Must have signature `fn map_routes(router: Router<S>) -> Router<S>`.
95/// The macro fills in the body to register all routes:
96///
97/// ```ignore
98/// #[oxapi(map)]
99/// fn map_routes(router: Router<S>) -> Router<S>;
100/// // Generates body: router.route("/pet/{petId}", get(Self::get_pet)).route(...)
101/// ```
102///
103/// ## `#[oxapi(method, "path")]`
104///
105/// Maps a trait method to an OpenAPI operation. Supported methods: `get`, `post`, `put`, `delete`, `patch`, `head`, `options`.
106///
107/// ```ignore
108/// #[oxapi(get, "/pet/{petId}")]
109/// async fn get_pet(state: State<S>, pet_id: Path<_>);
110///
111/// #[oxapi(post, "/pet")]
112/// async fn add_pet(state: State<S>, body: Json<_>);
113/// ```
114///
115/// ## `#[oxapi(spec, "endpoint_path")]`
116///
117/// Serves the embedded OpenAPI spec at the given endpoint path. The method must be completely bare
118/// (no parameters, no return type, not async, no generics). The macro generates:
119/// - A method that returns `&'static str` containing the spec contents via `include_str!`
120/// - A GET route at the specified path (added to `map_routes`)
121///
122/// This endpoint does not appear in the OpenAPI spec itself but can be used for validation purposes.
123///
124/// ```ignore
125/// #[oxapi(spec, "/openapi.yaml")]
126/// fn spec();
127/// // Generates: fn spec() -> &'static str { include_str!("path/to/spec.yaml") }
128/// // And adds: .route("/openapi.yaml", get(|| async { Self::spec() }))
129/// ```
130///
131/// # Type Elision
132///
133/// Use `_` as a type parameter to have the macro infer the correct type from the OpenAPI spec:
134///
135/// - `Path<_>` → Inferred from path parameters (single type or tuple for multiple)
136/// - `Query<_>` → Generated query struct `{OperationId}Query`
137/// - `Json<_>` → Inferred from request body schema
138/// - `State<S>` → Passed through unchanged (user-provided)
139///
140/// ```ignore
141/// #[oxapi(get, "/pet/{petId}")]
142/// async fn get_pet(
143/// state: State<S>, // User state, unchanged
144/// pet_id: Path<_>, // Becomes Path<i64> based on spec
145/// query: Query<_>, // Becomes Query<GetPetQuery>
146/// );
147///
148/// #[oxapi(post, "/pet")]
149/// async fn add_pet(
150/// state: State<S>,
151/// body: Json<_>, // Becomes Json<Pet> based on spec
152/// );
153/// ```
154///
155/// # Generated Types
156///
157/// For each operation, the macro generates:
158///
159/// - `{OperationId}{ok_suffix}` - Enum with success response variants (2xx status codes), default suffix is `Response`
160/// - `{OperationId}{err_suffix}` - Enum with error response variants (4xx, 5xx, default), default suffix is `Error`
161/// - `{OperationId}Query` - Struct for query parameters (if operation has query params)
162///
163/// All response enums implement `axum::response::IntoResponse`.
164///
165/// # Type Customization
166///
167/// ## Schema Type Conversion
168///
169/// Replace JSON schema constructs with custom types using `#[convert(...)]` on the module:
170///
171/// ```ignore
172/// #[oxapi(axum, "spec.json")]
173/// #[convert(into = uuid::Uuid, type = "string", format = "uuid")]
174/// #[convert(into = rust_decimal::Decimal, type = "number")]
175/// mod api {
176/// // All schemas with type="string", format="uuid" use uuid::Uuid
177/// // All schemas with type="number" use rust_decimal::Decimal
178/// }
179/// ```
180///
181/// ### Convert Attribute Fields
182///
183/// - `into`: The replacement Rust type (required)
184/// - `type`: JSON schema type (`"string"`, `"number"`, `"integer"`, `"boolean"`, `"array"`, `"object"`)
185/// - `format`: JSON schema format (e.g., `"uuid"`, `"date-time"`, `"uri"`)
186///
187/// ## Schema Type Patching (Rename/Derive)
188///
189/// Rename schema types or add derives using struct declarations:
190///
191/// ```ignore
192/// #[oxapi(axum, "spec.json")]
193/// mod api {
194/// // Rename "Veggie" from the schema to "Vegetable" in generated code
195/// #[oxapi(Veggie)]
196/// struct Vegetable;
197///
198/// // Add extra derives to a schema type
199/// #[oxapi(Veggie)]
200/// #[derive(schemars::JsonSchema, PartialEq, Eq, Hash)]
201/// struct Vegetable;
202/// }
203/// ```
204///
205/// ## Schema Type Replacement
206///
207/// Replace a schema type entirely with an existing Rust type:
208///
209/// ```ignore
210/// #[oxapi(axum, "spec.json")]
211/// mod api {
212/// // Use my_networking::Ipv6Cidr instead of generating Ipv6Cidr
213/// #[oxapi]
214/// type Ipv6Cidr = my_networking::Ipv6Cidr;
215/// }
216/// ```
217///
218/// ## Generated Type Customization
219///
220/// Rename or replace the auto-generated `Response`/`Error`/`Query` types using operation coordinates:
221///
222/// ```ignore
223/// #[oxapi(axum, "spec.json")]
224/// mod api {
225/// // Rename GetPetByIdResponse to PetResponse
226/// #[oxapi(get, "/pet/{petId}", ok)]
227/// struct PetResponse;
228///
229/// // Rename GetPetByIdError to PetError
230/// #[oxapi(get, "/pet/{petId}", err)]
231/// struct PetError;
232///
233/// // Rename FindPetsByStatusQuery to PetSearchParams
234/// #[oxapi(get, "/pet/findByStatus", query)]
235/// struct PetSearchParams;
236///
237/// // Replace GetPetByIdResponse with a custom type (skips generation)
238/// #[oxapi(get, "/pet/{petId}", ok)]
239/// type _ = my_types::PetResponse;
240///
241/// // Replace GetPetByIdError with a custom type
242/// #[oxapi(get, "/pet/{petId}", err)]
243/// type _ = my_types::PetError;
244/// }
245/// ```
246///
247/// ## Enum Variant Renaming
248///
249/// Rename individual status code variants within response enums using the enum syntax:
250///
251/// ```ignore
252/// #[oxapi(axum, "spec.json")]
253/// mod api {
254/// // Rename the enum to PetError and customize specific variant names
255/// #[oxapi(get, "/pet/{petId}", err)]
256/// enum PetError {
257/// #[oxapi(status = 401)]
258/// Unauthorized,
259/// #[oxapi(status = 404)]
260/// NotFound,
261/// }
262/// // Generates: enum PetError { Unauthorized(...), NotFound(...), Status500(...), ... }
263/// // instead of: enum GetPetByIdError { Status401(...), Status404(...), Status500(...), ... }
264/// }
265/// ```
266///
267/// Only status codes you specify will be renamed; others retain their default `Status{code}` names.
268///
269/// ### Inline Type Naming
270///
271/// When a response has an inline schema (not a `$ref`), oxapi generates a struct for it.
272/// The default name is `{EnumName}{VariantName}`. You can override this by specifying
273/// a type name in the variant:
274///
275/// ```ignore
276/// #[oxapi(axum, "spec.json")]
277/// mod api {
278/// #[oxapi(get, "/store/inventory", ok)]
279/// enum InventoryResponse {
280/// #[oxapi(status = 200)]
281/// Success(Inventory), // The inline schema struct will be named "Inventory"
282/// }
283/// }
284/// ```
285///
286/// Note: This only works for inline schemas. Using a type name with a `$ref` schema
287/// will result in a compile error.
288///
289/// ### Attribute Pass-Through
290///
291/// All attributes on the enum (except `#[oxapi(...)]`) and on variants (except `#[oxapi(...)]`)
292/// are passed through to the generated code. If you specify a `#[derive(...)]` on the enum,
293/// it completely overrides the default derives.
294///
295/// ```ignore
296/// #[oxapi(axum, "spec.json")]
297/// mod api {
298/// #[oxapi(get, "/pet/{petId}", err)]
299/// #[derive(Debug, thiserror::Error)] // Overrides default, must include Debug if needed
300/// enum PetError {
301/// #[oxapi(status = 401)]
302/// #[error("Unauthorized access")]
303/// Unauthorized,
304///
305/// #[oxapi(status = 404)]
306/// #[error("Pet not found: {0}")]
307/// NotFound(String),
308/// }
309/// }
310/// ```
311///
312/// ### Kind Values
313///
314/// - `ok` - Success response enum (`{OperationId}{ok_suffix}`, default: `{OperationId}Response`). Supports variant renames with enum syntax.
315/// - `err` - Error response enum (`{OperationId}{err_suffix}`, default: `{OperationId}Error`). Supports variant renames with enum syntax.
316/// - `query` - Query parameters struct (`{OperationId}Query`)
317///
318/// # Complete Example
319///
320/// ```ignore
321/// use axum::{Router, extract::{State, Path, Query, Json}};
322///
323/// #[oxapi::oxapi(axum, "petstore.json")]
324/// #[convert(into = uuid::Uuid, type = "string", format = "uuid")]
325/// mod api {
326/// // Rename a schema type (Pet from OpenAPI becomes Animal in Rust)
327/// #[oxapi(Pet)]
328/// #[derive(Clone)]
329/// struct Animal;
330///
331/// // Replace a generated response type
332/// #[oxapi(get, "/pet/{petId}", err)]
333/// type _ = crate::errors::PetNotFound;
334///
335/// trait PetService<S: Clone + Send + Sync + 'static> {
336/// #[oxapi(map)]
337/// fn map_routes(router: Router<S>) -> Router<S>;
338///
339/// #[oxapi(get, "/pet/{petId}")]
340/// async fn get_pet(state: State<S>, pet_id: Path<_>);
341///
342/// #[oxapi(post, "/pet")]
343/// async fn add_pet(state: State<S>, body: Json<_>);
344///
345/// #[oxapi(get, "/pet/findByStatus")]
346/// async fn find_by_status(state: State<S>, query: Query<_>);
347/// }
348/// }
349///
350/// // Implement the trait
351/// struct MyPetService;
352///
353/// impl api::PetService<AppState> for MyPetService {
354/// fn map_routes(router: Router<AppState>) -> Router<AppState> {
355/// <Self as api::PetService<AppState>>::map_routes(router)
356/// }
357///
358/// async fn get_pet(
359/// State(state): State<AppState>,
360/// Path(pet_id): Path<i64>,
361/// ) -> Result<api::types::GetPetByIdResponse, crate::errors::PetNotFound> {
362/// // Implementation
363/// }
364/// // ...
365/// }
366/// ```
367#[proc_macro_attribute]
368pub fn oxapi(attr: TokenStream, item: TokenStream) -> TokenStream {
369 match do_oxapi(attr.into(), item.into()) {
370 Ok(tokens) => tokens.into(),
371 Err(err) => err.to_compile_error().into(),
372 }
373}
374
375fn do_oxapi(attr: TokenStream2, item: TokenStream2) -> syn::Result<TokenStream2> {
376 // Parse the attribute arguments: (framework, "spec_path")
377 let args = syn::parse2::<MacroArgs>(attr)?;
378
379 // Build response suffixes from macro args
380 let response_suffixes = oxapi_impl::ResponseSuffixes {
381 ok_suffix: args.ok_suffix.clone(),
382 err_suffix: args.err_suffix.clone(),
383 default_derives: args
384 .default_derives
385 .clone()
386 .unwrap_or_else(|| quote! { #[derive(Debug)] }),
387 };
388
389 // Try to parse as trait first, then as module
390 if let Ok(trait_item) = syn::parse2::<ItemTrait>(item.clone()) {
391 // Load the OpenAPI spec with default settings for traits
392 let spec_path = resolve_spec_path(&args.spec_path)?;
393 let generator = oxapi_impl::Generator::builder_from_file(&spec_path)
394 .map_err(|e| {
395 syn::Error::new(args.spec_path.span(), format!("failed to load spec: {}", e))
396 })?
397 .response_suffixes(response_suffixes)
398 .build()
399 .map_err(|e| {
400 syn::Error::new(args.spec_path.span(), format!("failed to load spec: {}", e))
401 })?;
402 let processor = TraitProcessor::new(generator, trait_item, spec_path)?;
403 processor.generate()
404 } else if let Ok(mod_item) = syn::parse2::<ItemMod>(item) {
405 // Parse convert attributes from module and build settings
406 let (settings, overrides, schema_renames) = build_type_settings(&mod_item)?;
407
408 // Convert to HashMap<String, String> for the generator (original_name -> new_name)
409 let schema_renames_for_gen: std::collections::HashMap<String, String> = schema_renames
410 .iter()
411 .map(|(k, rename)| (k.clone(), rename.new.to_string()))
412 .collect();
413
414 // Load the OpenAPI spec with custom settings
415 // Validation of schema renames happens during TypeGenerator construction
416 let spec_path = resolve_spec_path(&args.spec_path)?;
417 let generator = oxapi_impl::Generator::builder_from_file(&spec_path)
418 .map_err(|e| syn::Error::new(args.spec_path.span(), format!("{}", e)))?
419 .settings(settings)
420 .type_overrides(overrides)
421 .response_suffixes(response_suffixes)
422 .schema_renames(schema_renames_for_gen)
423 .build()
424 .map_err(|e| {
425 // Check if this is an UnknownSchema error and use the span from the original ident
426 if let oxapi_impl::Error::UnknownSchema { ref name, .. } = e
427 && let Some(rename) = schema_renames.get(name)
428 {
429 return syn::Error::new(rename.original.span(), format!("{}", e));
430 }
431 syn::Error::new(args.spec_path.span(), format!("{}", e))
432 })?;
433
434 let processor = ModuleProcessor::new(generator, mod_item, args.unwrap, spec_path)?;
435 processor.generate()
436 } else {
437 Err(syn::Error::new(
438 proc_macro2::Span::call_site(),
439 "expected trait or mod item",
440 ))
441 }
442}
443
444/// Resolve the spec path relative to CARGO_MANIFEST_DIR.
445fn resolve_spec_path(lit: &LitStr) -> syn::Result<std::path::PathBuf> {
446 let dir = std::env::var("CARGO_MANIFEST_DIR")
447 .map_err(|_| syn::Error::new(lit.span(), "CARGO_MANIFEST_DIR not set"))?;
448
449 let path = std::path::Path::new(&dir).join(lit.value());
450 Ok(path)
451}
452
453/// Rename tracking original and new tokens for name resolution and error reporting.
454struct Rename<T> {
455 original: T,
456 new: T,
457}
458
459/// Build TypeSpaceSettings, TypeOverrides, and schema renames from module attributes and items.
460fn build_type_settings(
461 mod_item: &ItemMod,
462) -> syn::Result<(
463 oxapi_impl::TypeSpaceSettings,
464 oxapi_impl::TypeOverrides,
465 std::collections::HashMap<String, Rename<Ident>>,
466)> {
467 let mut settings = oxapi_impl::TypeSpaceSettings::default();
468 let mut overrides = oxapi_impl::TypeOverrides::new();
469 let mut schema_renames = std::collections::HashMap::new();
470
471 // Parse #[convert(...)] attributes on the module
472 for attr in find_convert_attrs(&mod_item.attrs)? {
473 let schema = attr.to_schema();
474 let type_name = attr.into.to_token_stream().to_string();
475 settings.with_conversion(schema, type_name, std::iter::empty());
476 }
477
478 // Parse items inside the module for patches and replacements
479 if let Some((_, content)) = &mod_item.content {
480 for item in content {
481 match item {
482 // Struct with #[oxapi(...)] for renames
483 syn::Item::Struct(s) => {
484 if let Some(oxapi_attr) = find_struct_oxapi_attr(&s.attrs)? {
485 match oxapi_attr {
486 StructOxapiAttr::Schema(original_ident) => {
487 // Schema type rename: #[oxapi(OriginalName)] struct NewName;
488 // The attribute specifies the schema name from OpenAPI,
489 // the struct ident is what it gets renamed to.
490 let original_name = original_ident.to_string();
491 let new_name = s.ident.to_string();
492 let mut patch = oxapi_impl::TypeSpacePatch::default();
493 patch.with_rename(&new_name);
494
495 // Also add any derives
496 for derive_path in find_derives(&s.attrs)? {
497 patch.with_derive(derive_path.to_token_stream().to_string());
498 }
499
500 settings.with_patch(&original_name, &patch);
501 schema_renames.insert(
502 original_name,
503 Rename {
504 original: original_ident,
505 new: s.ident.clone(),
506 },
507 );
508 }
509 StructOxapiAttr::Operation(op) => {
510 // Generated type rename: #[oxapi(get, "/path", ok)] struct NewName;
511 overrides.add_rename(
512 op.method,
513 op.path.value(),
514 op.kind,
515 s.ident.clone(),
516 );
517 }
518 }
519 }
520 }
521
522 // Enum with #[oxapi(method, "path", kind)] for variant renames
523 syn::Item::Enum(e) => {
524 if let Some(op) = find_enum_oxapi_attr(&e.attrs)? {
525 let attrs = extract_passthrough_attrs(&e.attrs);
526 let variant_overrides = parse_variant_overrides(&e.variants)?;
527 overrides.add_rename_with_overrides(
528 op.method,
529 op.path.value(),
530 op.kind,
531 e.ident.clone(),
532 attrs,
533 variant_overrides,
534 );
535 }
536 }
537
538 // Type alias with #[oxapi] or #[oxapi(method, path, kind)] for replacements
539 syn::Item::Type(t) => {
540 if let Some(oxapi_attr) = find_type_alias_oxapi_attr(&t.attrs)? {
541 match oxapi_attr {
542 TypeAliasOxapiAttr::Operation(op) => {
543 // Generated type replacement: #[oxapi(get, "/path", ok)] type _ = T;
544 let replacement = t.ty.to_token_stream();
545 overrides.add_replace(
546 op.method,
547 op.path.value(),
548 op.kind,
549 replacement,
550 );
551 }
552 TypeAliasOxapiAttr::Schema => {
553 // Schema type replacement: #[oxapi] type Name = T;
554 let type_name = t.ident.to_string();
555 let replace_type = t.ty.to_token_stream().to_string();
556 settings.with_replacement(
557 &type_name,
558 &replace_type,
559 std::iter::empty(),
560 );
561 }
562 }
563 }
564 }
565
566 _ => {}
567 }
568 }
569 }
570
571 Ok((settings, overrides, schema_renames))
572}
573
574/// Parsed macro arguments.
575struct MacroArgs {
576 spec_path: LitStr,
577 unwrap: bool,
578 ok_suffix: String,
579 err_suffix: String,
580 /// Default derives for generated enums (None means use Debug)
581 default_derives: Option<TokenStream2>,
582}
583
584impl syn::parse::Parse for MacroArgs {
585 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
586 let framework: Ident = input.parse()?;
587 input.parse::<Token![,]>()?;
588 let spec_path: LitStr = input.parse()?;
589
590 // Parse optional arguments (unordered)
591 let mut unwrap = false;
592 let mut ok_suffix: Option<String> = None;
593 let mut err_suffix: Option<String> = None;
594 let mut default_derives: Option<TokenStream2> = None;
595
596 while input.peek(Token![,]) {
597 input.parse::<Token![,]>()?;
598
599 // Check if this is a key = value pair or a bare identifier
600 let ident: Ident = input.parse()?;
601
602 if input.peek(Token![=]) {
603 // key = value syntax
604 input.parse::<Token![=]>()?;
605
606 match ident.to_string().as_str() {
607 "ok_suffix" => {
608 let value: LitStr = input.parse()?;
609 if ok_suffix.is_some() {
610 return Err(syn::Error::new(
611 ident.span(),
612 "ok_suffix specified more than once",
613 ));
614 }
615 ok_suffix = Some(value.value());
616 }
617 "err_suffix" => {
618 let value: LitStr = input.parse()?;
619 if err_suffix.is_some() {
620 return Err(syn::Error::new(
621 ident.span(),
622 "err_suffix specified more than once",
623 ));
624 }
625 err_suffix = Some(value.value());
626 }
627 "derive" => {
628 // Parse derive = (Trait1, Trait2, ...)
629 if default_derives.is_some() {
630 return Err(syn::Error::new(
631 ident.span(),
632 "derive specified more than once",
633 ));
634 }
635 let content;
636 syn::parenthesized!(content in input);
637 let derives: syn::punctuated::Punctuated<syn::Path, Token![,]> =
638 content.parse_terminated(syn::Path::parse, Token![,])?;
639 default_derives = Some(quote! { #[derive(#derives)] });
640 }
641 other => {
642 return Err(syn::Error::new(
643 ident.span(),
644 format!("unknown option: {}", other),
645 ));
646 }
647 }
648 } else {
649 // Bare identifier (flag)
650 match ident.to_string().as_str() {
651 "unwrap" => {
652 if unwrap {
653 return Err(syn::Error::new(
654 ident.span(),
655 "unwrap specified more than once",
656 ));
657 }
658 unwrap = true;
659 }
660 other => {
661 return Err(syn::Error::new(
662 ident.span(),
663 format!(
664 "unknown option: {} (expected 'unwrap', 'ok_suffix = \"...\"', 'err_suffix = \"...\"', or 'derive = (...)')",
665 other
666 ),
667 ));
668 }
669 }
670 }
671 }
672
673 // Only axum is supported for now
674 if framework.to_string().as_str() != "axum" {
675 return Err(syn::Error::new(
676 framework.span(),
677 format!("unsupported framework: {}", framework),
678 ));
679 }
680
681 Ok(MacroArgs {
682 spec_path,
683 unwrap,
684 ok_suffix: ok_suffix.unwrap_or_else(|| "Response".to_string()),
685 err_suffix: err_suffix.unwrap_or_else(|| "Error".to_string()),
686 default_derives,
687 })
688 }
689}
690
691/// Processes a trait and generates the output.
692struct TraitProcessor {
693 generator: oxapi_impl::Generator,
694 trait_item: ItemTrait,
695 /// Resolved path to the spec file for include_str!
696 spec_path: std::path::PathBuf,
697}
698
699impl TraitProcessor {
700 fn new(
701 generator: oxapi_impl::Generator,
702 trait_item: ItemTrait,
703 spec_path: std::path::PathBuf,
704 ) -> syn::Result<Self> {
705 Ok(Self {
706 generator,
707 trait_item,
708 spec_path,
709 })
710 }
711
712 fn generate(self) -> syn::Result<TokenStream2> {
713 use std::collections::HashMap;
714
715 let trait_name = &self.trait_item.ident;
716 let types_mod_name = syn::Ident::new(
717 &format!("{}_types", heck::AsSnakeCase(trait_name.to_string())),
718 trait_name.span(),
719 );
720
721 // Parse all method attributes and collect coverage info
722 let mut covered: HashMap<(oxapi_impl::HttpMethod, String), ()> = HashMap::new();
723 let mut map_method: Option<&syn::TraitItemFn> = None;
724 let mut spec_method: Option<(&syn::TraitItemFn, String)> = None;
725 let mut handler_methods: Vec<(&syn::TraitItemFn, oxapi_impl::HttpMethod, String)> =
726 Vec::new();
727
728 for item in &self.trait_item.items {
729 if let syn::TraitItem::Fn(method) = item {
730 if let Some(attr) = find_oxapi_attr(&method.attrs)? {
731 match attr {
732 OxapiAttr::Map => {
733 map_method = Some(method);
734 }
735 OxapiAttr::Route {
736 method: http_method,
737 path,
738 } => {
739 covered.insert((http_method, path.clone()), ());
740 handler_methods.push((method, http_method, path));
741 }
742 OxapiAttr::Spec { path } => {
743 validate_bare_spec_method(method)?;
744 // Spec routes count as GET coverage if the path exists in OpenAPI
745 covered.insert((oxapi_impl::HttpMethod::Get, path.clone()), ());
746 spec_method = Some((method, path));
747 }
748 }
749 } else {
750 return Err(syn::Error::new_spanned(
751 method,
752 "all trait methods must have #[oxapi(...)] attribute",
753 ));
754 }
755 }
756 }
757
758 // Validate coverage
759 self.generator
760 .validate_coverage(&covered)
761 .map_err(|e| syn::Error::new_spanned(&self.trait_item, e.to_string()))?;
762
763 // Generate types module
764 let types = self.generator.generate_types();
765 let responses = self.generator.generate_responses();
766 let query_structs = self.generator.generate_query_structs();
767
768 // Generate transformed methods
769 let mut transformed_methods = Vec::new();
770
771 // Generate map_routes if present
772 if let Some(map_fn) = map_method {
773 let map_body = oxapi_impl::RouterGenerator.generate_map_routes(
774 &handler_methods
775 .iter()
776 .map(|(m, method, path)| (m.sig.ident.clone(), *method, path.clone()))
777 .collect::<Vec<_>>(),
778 spec_method
779 .as_ref()
780 .map(|(m, path)| (m.sig.ident.clone(), path.clone())),
781 );
782
783 let sig = &map_fn.sig;
784 transformed_methods.push(quote! {
785 #sig {
786 #map_body
787 }
788 });
789 }
790
791 // Generate handler methods
792 let method_transformer =
793 oxapi_impl::MethodTransformer::new(&self.generator, &types_mod_name);
794 for (method, http_method, path) in &handler_methods {
795 let op = self
796 .generator
797 .get_operation(*http_method, path)
798 .ok_or_else(|| {
799 syn::Error::new_spanned(
800 method,
801 format!("operation not found: {} {}", http_method, path),
802 )
803 })?;
804
805 let transformed = method_transformer.transform(method, op)?;
806 transformed_methods.push(transformed);
807 }
808
809 // Generate spec method if present
810 if let Some((method, _endpoint_path)) = &spec_method {
811 let method_name = &method.sig.ident;
812 let spec_file_path = self.spec_path.to_string_lossy();
813 transformed_methods.push(quote! {
814 fn #method_name() -> &'static str {
815 include_str!(#spec_file_path)
816 }
817 });
818 }
819
820 // Generate the full output
821 let vis = &self.trait_item.vis;
822 let trait_attrs: Vec<_> = self
823 .trait_item
824 .attrs
825 .iter()
826 .filter(|a| !a.path().is_ident("oxapi"))
827 .collect();
828
829 // Preserve generic parameters from the original trait
830 let generics = &self.trait_item.generics;
831 let where_clause = &generics.where_clause;
832
833 // Use trait name span so errors point to user's trait definition
834 let trait_def = quote_spanned! { trait_name.span() =>
835 #(#trait_attrs)*
836 #vis trait #trait_name #generics: 'static #where_clause {
837 #(#transformed_methods)*
838 }
839 };
840
841 let output = quote! {
842 #vis mod #types_mod_name {
843 use super::*;
844
845 #types
846 #responses
847 #query_structs
848 }
849
850 #trait_def
851 };
852
853 Ok(output)
854 }
855}
856
857/// Processes a module containing multiple traits.
858struct ModuleProcessor {
859 generator: oxapi_impl::Generator,
860 /// Module wrapper (visibility, name) - None if unwrap mode
861 mod_wrapper: Option<(syn::Visibility, Ident)>,
862 /// Module content items
863 content: Vec<syn::Item>,
864 /// Module span for error reporting
865 mod_span: proc_macro2::Span,
866 /// Resolved path to the spec file for include_str!
867 spec_path: std::path::PathBuf,
868}
869
870impl ModuleProcessor {
871 fn new(
872 generator: oxapi_impl::Generator,
873 mod_item: ItemMod,
874 unwrap: bool,
875 spec_path: std::path::PathBuf,
876 ) -> syn::Result<Self> {
877 // Module must have content (not just a declaration)
878 let mod_span = mod_item.ident.span();
879 let (_, content) = mod_item.content.ok_or_else(|| {
880 syn::Error::new(
881 mod_span,
882 "module must have inline content, not just a declaration",
883 )
884 })?;
885
886 let mod_wrapper = if unwrap {
887 None
888 } else {
889 Some((mod_item.vis, mod_item.ident))
890 };
891
892 Ok(Self {
893 generator,
894 mod_wrapper,
895 content,
896 mod_span,
897 spec_path,
898 })
899 }
900
901 fn generate(self) -> syn::Result<TokenStream2> {
902 use std::collections::HashMap;
903
904 // Find all traits in the module, filtering out patch/replace items
905 let mut traits: Vec<&ItemTrait> = Vec::new();
906 let mut other_items: Vec<&syn::Item> = Vec::new();
907
908 for item in &self.content {
909 match item {
910 syn::Item::Trait(t) => traits.push(t),
911 // Skip structs with #[oxapi(...)] (patch/override items)
912 syn::Item::Struct(s) if find_struct_oxapi_attr(&s.attrs)?.is_some() => {}
913 // Skip enums with #[oxapi(...)] (variant rename items)
914 syn::Item::Enum(e) if find_enum_oxapi_attr(&e.attrs)?.is_some() => {}
915 // Skip type aliases with #[oxapi] or #[oxapi(...)] (replacement items)
916 syn::Item::Type(t) if has_oxapi_attr(&t.attrs) => {}
917 other => other_items.push(other),
918 }
919 }
920
921 if traits.is_empty() {
922 return Err(syn::Error::new(
923 self.mod_span,
924 "module must contain at least one trait",
925 ));
926 }
927
928 // Collect all operations across all traits for coverage validation
929 let mut all_covered: HashMap<(oxapi_impl::HttpMethod, String), ()> = HashMap::new();
930
931 // Process each trait and collect handler info
932 struct TraitInfo<'a> {
933 trait_item: &'a ItemTrait,
934 map_method: Option<&'a syn::TraitItemFn>,
935 spec_method: Option<(&'a syn::TraitItemFn, String)>,
936 handler_methods: Vec<(&'a syn::TraitItemFn, oxapi_impl::HttpMethod, String)>,
937 }
938
939 let mut trait_infos: Vec<TraitInfo> = Vec::new();
940
941 for trait_item in &traits {
942 let mut map_method: Option<&syn::TraitItemFn> = None;
943 let mut spec_method: Option<(&syn::TraitItemFn, String)> = None;
944 let mut handler_methods: Vec<(&syn::TraitItemFn, oxapi_impl::HttpMethod, String)> =
945 Vec::new();
946
947 for item in &trait_item.items {
948 if let syn::TraitItem::Fn(method) = item {
949 if let Some(attr) = find_oxapi_attr(&method.attrs)? {
950 match attr {
951 OxapiAttr::Map => {
952 map_method = Some(method);
953 }
954 OxapiAttr::Route {
955 method: http_method,
956 path,
957 } => {
958 // Check for duplicates across traits
959 let key = (http_method, path.clone());
960 if all_covered.contains_key(&key) {
961 return Err(syn::Error::new_spanned(
962 method,
963 format!(
964 "operation {} {} is already defined in another trait",
965 http_method, path
966 ),
967 ));
968 }
969 all_covered.insert(key, ());
970 handler_methods.push((method, http_method, path));
971 }
972 OxapiAttr::Spec { path } => {
973 validate_bare_spec_method(method)?;
974 // Spec routes count as GET coverage if the path exists in OpenAPI
975 all_covered.insert((oxapi_impl::HttpMethod::Get, path.clone()), ());
976 spec_method = Some((method, path));
977 }
978 }
979 } else {
980 return Err(syn::Error::new_spanned(
981 method,
982 "all trait methods must have #[oxapi(...)] attribute",
983 ));
984 }
985 }
986 }
987
988 trait_infos.push(TraitInfo {
989 trait_item,
990 map_method,
991 spec_method,
992 handler_methods,
993 });
994 }
995
996 // Validate coverage against spec
997 self.generator
998 .validate_coverage(&all_covered)
999 .map_err(|e| syn::Error::new(self.mod_span, e.to_string()))?;
1000
1001 // Generate shared types module
1002 let types = self.generator.generate_types();
1003 let responses = self.generator.generate_responses();
1004 let query_structs = self.generator.generate_query_structs();
1005
1006 // Generate each trait
1007 let types_mod_name = syn::Ident::new("types", proc_macro2::Span::call_site());
1008 let method_transformer =
1009 oxapi_impl::MethodTransformer::new(&self.generator, &types_mod_name);
1010
1011 let mut generated_traits = Vec::new();
1012
1013 for info in &trait_infos {
1014 let trait_name = &info.trait_item.ident;
1015 let _trait_vis = &info.trait_item.vis;
1016 let trait_attrs: Vec<_> = info
1017 .trait_item
1018 .attrs
1019 .iter()
1020 .filter(|a| !a.path().is_ident("oxapi"))
1021 .collect();
1022
1023 let mut transformed_methods = Vec::new();
1024
1025 // Generate map_routes if present
1026 if let Some(map_fn) = info.map_method {
1027 let map_body = oxapi_impl::RouterGenerator.generate_map_routes(
1028 &info
1029 .handler_methods
1030 .iter()
1031 .map(|(m, method, path)| (m.sig.ident.clone(), *method, path.clone()))
1032 .collect::<Vec<_>>(),
1033 info.spec_method
1034 .as_ref()
1035 .map(|(m, path)| (m.sig.ident.clone(), path.clone())),
1036 );
1037
1038 let sig = &map_fn.sig;
1039 transformed_methods.push(quote! {
1040 #sig {
1041 #map_body
1042 }
1043 });
1044 }
1045
1046 // Generate handler methods
1047 for (method, http_method, path) in &info.handler_methods {
1048 let op = self
1049 .generator
1050 .get_operation(*http_method, path)
1051 .ok_or_else(|| {
1052 syn::Error::new_spanned(
1053 method,
1054 format!("operation not found: {} {}", http_method, path),
1055 )
1056 })?;
1057
1058 let transformed = method_transformer.transform(method, op)?;
1059 transformed_methods.push(transformed);
1060 }
1061
1062 // Generate spec method if present
1063 if let Some((method, _endpoint_path)) = &info.spec_method {
1064 let method_name = &method.sig.ident;
1065 let spec_file_path = self.spec_path.to_string_lossy();
1066 transformed_methods.push(quote! {
1067 fn #method_name() -> &'static str {
1068 include_str!(#spec_file_path)
1069 }
1070 });
1071 }
1072
1073 // Traits in module are always pub (for external use)
1074 // Preserve generic parameters from the original trait
1075 let generics = &info.trait_item.generics;
1076 let where_clause = &generics.where_clause;
1077 // Use trait name span so errors point to user's trait definition
1078 generated_traits.push(quote_spanned! { trait_name.span() =>
1079 #(#trait_attrs)*
1080 pub trait #trait_name #generics: 'static #where_clause {
1081 #(#transformed_methods)*
1082 }
1083 });
1084 }
1085
1086 // Generate the output
1087 let inner = quote! {
1088 pub mod #types_mod_name {
1089 use super::*;
1090
1091 #types
1092 #responses
1093 #query_structs
1094 }
1095
1096 #(#generated_traits)*
1097 };
1098
1099 let output = if let Some((vis, name)) = &self.mod_wrapper {
1100 quote! {
1101 #vis mod #name {
1102 use super::*;
1103 #inner
1104 }
1105 }
1106 } else {
1107 inner
1108 };
1109
1110 Ok(output)
1111 }
1112}
1113
1114/// Find and parse the #[oxapi(...)] attribute on a method.
1115fn find_oxapi_attr(attrs: &[syn::Attribute]) -> syn::Result<Option<OxapiAttr>> {
1116 for attr in attrs {
1117 if attr.path().is_ident("oxapi") {
1118 return attr.parse_args::<OxapiAttr>().map(Some);
1119 }
1120 }
1121 Ok(None)
1122}
1123
1124/// Parsed #[oxapi(...)] attribute.
1125enum OxapiAttr {
1126 Map,
1127 Route {
1128 method: oxapi_impl::HttpMethod,
1129 path: String,
1130 },
1131 /// Spec endpoint: `#[oxapi(spec, "/openapi.yaml")]`
1132 /// Returns the embedded spec contents at the given path.
1133 Spec {
1134 path: String,
1135 },
1136}
1137
1138impl syn::parse::Parse for OxapiAttr {
1139 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
1140 let ident: Ident = input.parse()?;
1141
1142 match ident.to_string().as_str() {
1143 "map" => Ok(OxapiAttr::Map),
1144 "spec" => {
1145 input.parse::<Token![,]>()?;
1146 let path: LitStr = input.parse()?;
1147 Ok(OxapiAttr::Spec { path: path.value() })
1148 }
1149 _ => {
1150 let method = parse_http_method(&ident)?;
1151 input.parse::<Token![,]>()?;
1152 let path: LitStr = input.parse()?;
1153 Ok(OxapiAttr::Route {
1154 method,
1155 path: path.value(),
1156 })
1157 }
1158 }
1159 }
1160}
1161
1162fn parse_http_method(ident: &Ident) -> syn::Result<oxapi_impl::HttpMethod> {
1163 match ident.to_string().as_str() {
1164 "get" => Ok(oxapi_impl::HttpMethod::Get),
1165 "post" => Ok(oxapi_impl::HttpMethod::Post),
1166 "put" => Ok(oxapi_impl::HttpMethod::Put),
1167 "delete" => Ok(oxapi_impl::HttpMethod::Delete),
1168 "patch" => Ok(oxapi_impl::HttpMethod::Patch),
1169 "head" => Ok(oxapi_impl::HttpMethod::Head),
1170 "options" => Ok(oxapi_impl::HttpMethod::Options),
1171 other => Err(syn::Error::new(
1172 ident.span(),
1173 format!("unknown HTTP method: {}", other),
1174 )),
1175 }
1176}
1177
1178/// Validate that a spec method is completely bare: no parameters, no return type, not async.
1179fn validate_bare_spec_method(method: &syn::TraitItemFn) -> syn::Result<()> {
1180 // Check for async
1181 if method.sig.asyncness.is_some() {
1182 return Err(syn::Error::new_spanned(
1183 method.sig.asyncness,
1184 "spec method must not be async",
1185 ));
1186 }
1187
1188 // Check for parameters
1189 if !method.sig.inputs.is_empty() {
1190 return Err(syn::Error::new_spanned(
1191 &method.sig.inputs,
1192 "spec method must have no parameters",
1193 ));
1194 }
1195
1196 // Check for return type
1197 if !matches!(method.sig.output, syn::ReturnType::Default) {
1198 return Err(syn::Error::new_spanned(
1199 &method.sig.output,
1200 "spec method must have no return type (it will be generated)",
1201 ));
1202 }
1203
1204 // Check for generics
1205 if !method.sig.generics.params.is_empty() {
1206 return Err(syn::Error::new_spanned(
1207 &method.sig.generics,
1208 "spec method must not have generics",
1209 ));
1210 }
1211
1212 Ok(())
1213}
1214
1215/// Parsed #[convert(into = Type, type = "...", format = "...")] attribute.
1216struct ConvertAttr {
1217 into: syn::Type,
1218 type_field: Option<LitStr>,
1219 format: Option<LitStr>,
1220}
1221
1222impl syn::parse::Parse for ConvertAttr {
1223 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
1224 let mut into: Option<syn::Type> = None;
1225 let mut type_field: Option<LitStr> = None;
1226 let mut format: Option<LitStr> = None;
1227
1228 while !input.is_empty() {
1229 let key: Ident = input.parse()?;
1230 input.parse::<Token![=]>()?;
1231
1232 match key.to_string().as_str() {
1233 "into" => {
1234 into = Some(input.parse()?);
1235 }
1236 "type" => {
1237 type_field = Some(input.parse()?);
1238 }
1239 "format" => {
1240 format = Some(input.parse()?);
1241 }
1242 other => {
1243 return Err(syn::Error::new(
1244 key.span(),
1245 format!("unknown convert attribute: {}", other),
1246 ));
1247 }
1248 }
1249
1250 if !input.is_empty() {
1251 input.parse::<Token![,]>()?;
1252 }
1253 }
1254
1255 let into =
1256 into.ok_or_else(|| syn::Error::new(input.span(), "convert requires 'into' field"))?;
1257
1258 Ok(ConvertAttr {
1259 into,
1260 type_field,
1261 format,
1262 })
1263 }
1264}
1265
1266impl ConvertAttr {
1267 /// Build a schemars SchemaObject from the type/format fields.
1268 fn to_schema(&self) -> SchemaObject {
1269 let instance_type = self.type_field.as_ref().map(|t| {
1270 let ty = match t.value().as_str() {
1271 "string" => InstanceType::String,
1272 "number" => InstanceType::Number,
1273 "integer" => InstanceType::Integer,
1274 "boolean" => InstanceType::Boolean,
1275 "array" => InstanceType::Array,
1276 "object" => InstanceType::Object,
1277 "null" => InstanceType::Null,
1278 _ => InstanceType::String, // fallback
1279 };
1280 SingleOrVec::Single(Box::new(ty))
1281 });
1282
1283 SchemaObject {
1284 instance_type,
1285 format: self.format.as_ref().map(|f| f.value()),
1286 ..Default::default()
1287 }
1288 }
1289}
1290
1291/// Find all #[convert(...)] attributes on an item.
1292fn find_convert_attrs(attrs: &[syn::Attribute]) -> syn::Result<Vec<ConvertAttr>> {
1293 let mut result = Vec::new();
1294 for attr in attrs {
1295 if attr.path().is_ident("convert") {
1296 result.push(attr.parse_args::<ConvertAttr>()?);
1297 }
1298 }
1299 Ok(result)
1300}
1301
1302/// Parsed `#[oxapi(SchemaName)]` attribute for schema type renames.
1303struct SchemaRenameAttr(Ident);
1304
1305impl syn::parse::Parse for SchemaRenameAttr {
1306 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
1307 let ident: Ident = input.parse()?;
1308 Ok(SchemaRenameAttr(ident))
1309 }
1310}
1311
1312/// Parsed `#[oxapi(method, "path", kind)]` attribute for generated type renames/replaces.
1313struct OpAttr {
1314 method: oxapi_impl::HttpMethod,
1315 path: LitStr,
1316 kind: oxapi_impl::GeneratedTypeKind,
1317}
1318
1319impl syn::parse::Parse for OpAttr {
1320 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
1321 let method_ident: Ident = input.parse()?;
1322 let method = parse_http_method(&method_ident)?;
1323 input.parse::<Token![,]>()?;
1324 let path: LitStr = input.parse()?;
1325 input.parse::<Token![,]>()?;
1326 let kind_ident: Ident = input.parse()?;
1327 let kind = parse_type_kind(&kind_ident)?;
1328 Ok(OpAttr { method, path, kind })
1329 }
1330}
1331
1332fn parse_type_kind(ident: &Ident) -> syn::Result<oxapi_impl::GeneratedTypeKind> {
1333 match ident.to_string().as_str() {
1334 "ok" => Ok(oxapi_impl::GeneratedTypeKind::Ok),
1335 "err" => Ok(oxapi_impl::GeneratedTypeKind::Err),
1336 "query" => Ok(oxapi_impl::GeneratedTypeKind::Query),
1337 other => Err(syn::Error::new(
1338 ident.span(),
1339 format!("unknown type kind: {} (expected ok, err, or query)", other),
1340 )),
1341 }
1342}
1343
1344/// Parsed `#[oxapi(status = code)]` attribute on enum variant.
1345struct VariantStatusAttr(u16);
1346
1347impl syn::parse::Parse for VariantStatusAttr {
1348 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
1349 let ident: Ident = input.parse()?;
1350 if ident != "status" {
1351 return Err(syn::Error::new(
1352 ident.span(),
1353 format!("expected 'status', found '{}'", ident),
1354 ));
1355 }
1356 input.parse::<Token![=]>()?;
1357 let lit: syn::LitInt = input.parse()?;
1358 let code: u16 = lit.base10_parse()?;
1359 Ok(VariantStatusAttr(code))
1360 }
1361}
1362
1363/// Find `#[oxapi(status = code)]` attribute and return remaining attributes.
1364/// Returns (status_code, pass_through_attrs) where pass_through_attrs excludes the `#[oxapi]`.
1365/// Returns an error if more than one `#[oxapi]` attribute is found.
1366fn extract_variant_status_and_attrs(
1367 attrs: &[syn::Attribute],
1368) -> syn::Result<Option<(u16, Vec<proc_macro2::TokenStream>)>> {
1369 let mut found_status: Option<(u16, proc_macro2::Span)> = None;
1370 let mut pass_through = Vec::new();
1371
1372 for attr in attrs {
1373 if attr.path().is_ident("oxapi") {
1374 if found_status.is_some() {
1375 return Err(syn::Error::new_spanned(
1376 attr,
1377 "duplicate #[oxapi] attribute; only one #[oxapi(status = ...)] allowed per variant",
1378 ));
1379 }
1380 let status: VariantStatusAttr = attr.parse_args()?;
1381 found_status = Some((status.0, attr.path().get_ident().unwrap().span()));
1382 } else {
1383 pass_through.push(quote::quote! { #attr });
1384 }
1385 }
1386
1387 Ok(found_status.map(|(s, _)| (s, pass_through)))
1388}
1389
1390/// Parse variant overrides from an enum's variants.
1391/// Extracts variant name, optional inner type name (from tuple variant), and attributes.
1392fn parse_variant_overrides(
1393 variants: &syn::punctuated::Punctuated<syn::Variant, Token![,]>,
1394) -> syn::Result<std::collections::HashMap<u16, oxapi_impl::VariantOverride>> {
1395 let mut overrides = std::collections::HashMap::new();
1396 for variant in variants {
1397 if let Some((status, attrs)) = extract_variant_status_and_attrs(&variant.attrs)? {
1398 // Extract inner type name from tuple variant like `Success(TheResponse)`
1399 let inner_type_name = extract_inner_type_ident(&variant.fields)?;
1400
1401 overrides.insert(
1402 status,
1403 oxapi_impl::VariantOverride {
1404 name: variant.ident.clone(),
1405 inner_type_name,
1406 attrs,
1407 },
1408 );
1409 }
1410 }
1411 Ok(overrides)
1412}
1413
1414/// Extract the inner type identifier from a tuple variant's single field.
1415/// For `Success(TheResponse)`, returns `Some(TheResponse)`.
1416/// For `Success` (unit variant), returns `None`.
1417fn extract_inner_type_ident(fields: &syn::Fields) -> syn::Result<Option<syn::Ident>> {
1418 match fields {
1419 syn::Fields::Unit => Ok(None),
1420 syn::Fields::Unnamed(unnamed) => {
1421 if unnamed.unnamed.len() != 1 {
1422 return Err(syn::Error::new_spanned(
1423 unnamed,
1424 "variant must have exactly one field for inline type override",
1425 ));
1426 }
1427 let field = unnamed.unnamed.first().unwrap();
1428 // The type should be a simple path like `TheResponse`
1429 if let syn::Type::Path(type_path) = &field.ty
1430 && type_path.qself.is_none()
1431 && type_path.path.segments.len() == 1
1432 {
1433 let segment = type_path.path.segments.first().unwrap();
1434 if segment.arguments.is_empty() {
1435 return Ok(Some(segment.ident.clone()));
1436 }
1437 }
1438 Err(syn::Error::new_spanned(
1439 &field.ty,
1440 "inner type must be a simple identifier (e.g., `MyTypeName`)",
1441 ))
1442 }
1443 syn::Fields::Named(named) => Err(syn::Error::new_spanned(
1444 named,
1445 "named fields not supported for variant overrides; use tuple variant like `Success(TypeName)`",
1446 )),
1447 }
1448}
1449
1450/// Extract all attributes except `#[oxapi(...)]` as TokenStreams.
1451fn extract_passthrough_attrs(attrs: &[syn::Attribute]) -> Vec<proc_macro2::TokenStream> {
1452 attrs
1453 .iter()
1454 .filter(|attr| !attr.path().is_ident("oxapi"))
1455 .map(|attr| quote::quote! { #attr })
1456 .collect()
1457}
1458
1459/// Result of parsing a `#[oxapi(...)]` attribute on a struct (rename context).
1460enum StructOxapiAttr {
1461 /// Simple rename for schema types: `#[oxapi(SchemaName)]`
1462 Schema(Ident),
1463 /// Operation rename for generated types: `#[oxapi(get, "/path", ok)]`
1464 Operation(OpAttr),
1465}
1466
1467/// Find a single `#[oxapi(...)]` attribute and parse it with the given parser.
1468/// Returns an error if more than one `#[oxapi]` attribute is found.
1469fn find_single_oxapi_attr<T, F>(attrs: &[syn::Attribute], parser: F) -> syn::Result<Option<T>>
1470where
1471 F: FnOnce(&syn::Attribute) -> syn::Result<T>,
1472{
1473 let mut oxapi_attr: Option<&syn::Attribute> = None;
1474
1475 for attr in attrs {
1476 if attr.path().is_ident("oxapi") {
1477 if oxapi_attr.is_some() {
1478 return Err(syn::Error::new_spanned(
1479 attr,
1480 "duplicate #[oxapi] attribute; only one allowed per item",
1481 ));
1482 }
1483 oxapi_attr = Some(attr);
1484 }
1485 }
1486
1487 oxapi_attr.map(parser).transpose()
1488}
1489
1490/// Find `#[oxapi(...)]` attribute on a struct (for renames).
1491fn find_struct_oxapi_attr(attrs: &[syn::Attribute]) -> syn::Result<Option<StructOxapiAttr>> {
1492 find_single_oxapi_attr(attrs, |attr| {
1493 // Try operation syntax first, then schema syntax (single ident)
1494 if let Ok(op) = attr.parse_args::<OpAttr>() {
1495 Ok(StructOxapiAttr::Operation(op))
1496 } else {
1497 let schema: SchemaRenameAttr = attr.parse_args()?;
1498 Ok(StructOxapiAttr::Schema(schema.0))
1499 }
1500 })
1501}
1502
1503/// Result of parsing a `#[oxapi(...)]` attribute on a type alias (replace context).
1504enum TypeAliasOxapiAttr {
1505 /// Schema type replacement: `#[oxapi]` (no args)
1506 Schema,
1507 /// Operation replacement: `#[oxapi(get, "/path", ok)]`
1508 Operation(OpAttr),
1509}
1510
1511/// Find `#[oxapi(...)]` attribute on a type alias (for replacements).
1512fn find_type_alias_oxapi_attr(attrs: &[syn::Attribute]) -> syn::Result<Option<TypeAliasOxapiAttr>> {
1513 find_single_oxapi_attr(attrs, |attr| {
1514 // Check if it has arguments (operation style) or not (schema style)
1515 if let syn::Meta::Path(_) = &attr.meta {
1516 // No args: #[oxapi]
1517 Ok(TypeAliasOxapiAttr::Schema)
1518 } else if let Ok(op) = attr.parse_args::<OpAttr>() {
1519 Ok(TypeAliasOxapiAttr::Operation(op))
1520 } else {
1521 // No args but in list form: #[oxapi()]
1522 Ok(TypeAliasOxapiAttr::Schema)
1523 }
1524 })
1525}
1526
1527/// Check if an item has `#[oxapi]` attribute (any style).
1528fn has_oxapi_attr(attrs: &[syn::Attribute]) -> bool {
1529 attrs.iter().any(|attr| attr.path().is_ident("oxapi"))
1530}
1531
1532/// Find `#[oxapi(...)]` attribute on an enum (for renames).
1533/// For enums, we only support operation syntax: `#[oxapi(get, "/path", err)]`
1534fn find_enum_oxapi_attr(attrs: &[syn::Attribute]) -> syn::Result<Option<OpAttr>> {
1535 find_single_oxapi_attr(attrs, |attr| attr.parse_args())
1536}
1537
1538/// Find #[derive(...)] attributes and extract the derive paths.
1539fn find_derives(attrs: &[syn::Attribute]) -> syn::Result<Vec<syn::Path>> {
1540 let mut derives = Vec::new();
1541 for attr in attrs {
1542 if attr.path().is_ident("derive") {
1543 let nested: syn::punctuated::Punctuated<syn::Path, Token![,]> =
1544 attr.parse_args_with(syn::punctuated::Punctuated::parse_terminated)?;
1545 derives.extend(nested);
1546 }
1547 }
1548 Ok(derives)
1549}