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