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