From 5d17b88383c2add9b54041eed39a40103d7c232e Mon Sep 17 00:00:00 2001 From: oriontvv Date: Tue, 19 May 2026 15:40:37 +0300 Subject: [PATCH 1/2] feat: added support for the output of path parameters from the function signature and fixed the issue of cookie parameters. Now, when the `axum` feature is enabled, the path parameters are automatically derived from the function signature for `Path<...>`. Fixed: cookie parameters are now correctly added to the specification with `location: "cookie"`. --- README.md | 42 ++++ okapi-operation-macro/CHANGELOG.md | 22 ++ okapi-operation-macro/src/operation/cookie.rs | 4 +- okapi-operation-macro/src/operation/header.rs | 4 +- okapi-operation-macro/src/operation/mod.rs | 4 + .../src/operation/parameters.rs | 36 ++- okapi-operation-macro/src/operation/path.rs | 17 ++ .../src/operation/path_inference.rs | 119 ++++++++++ okapi-operation/CHANGELOG.md | 11 + okapi-operation/tests/axum_integration.rs | 207 ++++++++++++++++++ 10 files changed, 457 insertions(+), 9 deletions(-) create mode 100644 okapi-operation-macro/src/operation/path_inference.rs diff --git a/README.md b/README.md index d476816..31cec60 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,48 @@ fn main() { } ``` +## Path parameters from function signature (axum) + +With the `axum` feature enabled, path parameters are inferred from the +function signature, so handlers that use the axum `Path<...>` extractor no +longer need to repeat their names in `parameters(path(...))`. Two binding +shapes are recognized: + +```rust,no_run +use axum::extract::Path; +use okapi_operation::{axum_integration::*, *}; + +// Single path parameter — inferred as `system: String`. +#[openapi(operation_id = "get_system")] +async fn get_system(Path(system): Path) -> String { + system +} + +// Tuple — inferred in order: `system: String`, `backup_name: String`. +#[openapi(operation_id = "abort_backup")] +async fn abort_backup(Path((system, backup_name)): Path<(String, String)>) { + let _ = (system, backup_name); +} +``` + +Anything more involved (struct extractors, wildcard `_` bindings, references, +…) is left to explicit `parameters(path(...))` declarations, which always +take precedence over inferred entries with the same name. + +## Cookie parameters + +Cookie parameters are declared the same way as the other kinds: + +```rust,no_run +use okapi_operation::*; + +#[openapi( + operation_id = "me", + parameters(cookie(name = "session", required = true, schema = "String")), +)] +async fn me() {} +``` + ## Features - `macro`: enables re-import of `#[openapi]` macro (enabled by default); diff --git a/okapi-operation-macro/CHANGELOG.md b/okapi-operation-macro/CHANGELOG.md index 26702fa..1626bfc 100644 --- a/okapi-operation-macro/CHANGELOG.md +++ b/okapi-operation-macro/CHANGELOG.md @@ -3,6 +3,28 @@ All notable changes to this project will be documented in the changelog of the respective crates. This project follows the [Semantic Versioning standard](https://semver.org/). +## [Unreleased] + +### Added + +- Path parameters are now inferred from the function signature when the `axum` + feature is enabled. The axum `Path<...>` extractor is recognized in two + forms: `Path(name): Path` produces a single path parameter, and + `Path((a, b, ...)): Path<(T1, T2, ...)>` produces one parameter per tuple + position. Parameters declared explicitly via `parameters(path(...))` win + over inferred ones with the same name, so existing code is unaffected. + +### Fixed + +- `parameters(cookie(...))` entries are now actually emitted into the + generated operation. Previously cookie parameters were parsed but silently + dropped, so declared cookies never appeared in the spec. +- The cookie style field is now wrapped in `Some(...)`, fixing a compile + error that previously prevented `parameters(cookie(...))` from being used + at all. +- The cookie parameter `location` is now `"cookie"` (lowercase) as required + by OpenAPI 3.0. + ## [0.3.0] - 2025-06-15 Release `0.3.0` version. diff --git a/okapi-operation-macro/src/operation/cookie.rs b/okapi-operation-macro/src/operation/cookie.rs index 20338b4..3b678e1 100644 --- a/okapi-operation-macro/src/operation/cookie.rs +++ b/okapi-operation-macro/src/operation/cookie.rs @@ -39,14 +39,14 @@ impl ToTokens for Cookie { tokens.extend(quote! { okapi::openapi3::Parameter { name: #name.into(), - location: "Cookie".into(), + location: "cookie".into(), description: #description, required: #required, deprecated: #deprecated, allow_empty_value: #allow_empty_values, value: { okapi::openapi3::ParameterValue::Schema { - style: #style, + style: Some(#style), explode: #explode, allow_reserved: #allow_reserved, schema: components.schema_for::<#ty>(), diff --git a/okapi-operation-macro/src/operation/header.rs b/okapi-operation-macro/src/operation/header.rs index 0c1d7d5..1f5fde7 100644 --- a/okapi-operation-macro/src/operation/header.rs +++ b/okapi-operation-macro/src/operation/header.rs @@ -39,11 +39,11 @@ impl Header { } } - pub(super) fn for_parameter(&self) -> ParameterHeader { + pub(super) fn for_parameter(&self) -> ParameterHeader<'_> { ParameterHeader(self) } - pub(super) fn for_response(&self) -> ResponseHeader { + pub(super) fn for_response(&self) -> ResponseHeader<'_> { ResponseHeader(self) } } diff --git a/okapi-operation-macro/src/operation/mod.rs b/okapi-operation-macro/src/operation/mod.rs index 9a98be4..5d31362 100644 --- a/okapi-operation-macro/src/operation/mod.rs +++ b/okapi-operation-macro/src/operation/mod.rs @@ -18,6 +18,7 @@ mod external_docs; mod header; mod parameters; mod path; +mod path_inference; mod query; mod reference; mod request_body; @@ -138,6 +139,9 @@ pub(crate) fn openapi( operation_attrs .responses .add_return_type(&input, operation_attrs.responses.ignore_return_type); + operation_attrs + .parameters + .add_inferred_from_signature(&input); let request_body = RequestBody::from_item_fn(&mut input)?; let openapi_generator_fn = build_openapi_generator_fn(&input.sig.ident, &input.vis, operation_attrs, request_body)?; diff --git a/okapi-operation-macro/src/operation/parameters.rs b/okapi-operation-macro/src/operation/parameters.rs index 9d02266..3b4b9ee 100644 --- a/okapi-operation-macro/src/operation/parameters.rs +++ b/okapi-operation-macro/src/operation/parameters.rs @@ -1,7 +1,7 @@ use darling::FromMeta; use proc_macro2::TokenStream; use quote::{ToTokens, quote}; -use syn::{Meta, Token, punctuated::Punctuated}; +use syn::{ItemFn, Meta, Token, punctuated::Punctuated}; use super::cookie::{COOKIE_ATTRIBUTE_NAME, Cookie}; use crate::{ @@ -14,9 +14,6 @@ use crate::{ utils::meta_to_meta_list, }; -// TODO: support cookie parameters -// TODO: support parameters from function signature - #[derive(Debug, FromMeta)] #[darling(rename_all = "camelCase")] pub(super) enum ParameterStyle { @@ -44,7 +41,7 @@ impl ToTokens for ParameterStyle { } } -/// Parameters description (header/path/query) . +/// Parameters description (header/path/query/cookie). #[derive(Default, Debug)] pub(super) struct Parameters { header_parameters: Vec
, @@ -54,6 +51,33 @@ pub(super) struct Parameters { ref_parameters: Vec, } +impl Parameters { + /// Append parameters that can be inferred from the function signature + /// (currently: axum `Path<...>` extractor). Names already declared via the + /// macro arguments win — inferred entries with a duplicate name are + /// dropped. + pub(super) fn add_inferred_from_signature(&mut self, item_fn: &ItemFn) { + #[cfg(feature = "axum")] + { + let inferred = super::path_inference::infer_path_parameters(item_fn); + for param in inferred { + if self + .path_parameters + .iter() + .any(|p| p.name() == param.name()) + { + continue; + } + self.path_parameters.push(param); + } + } + #[cfg(not(feature = "axum"))] + { + let _ = item_fn; + } + } +} + impl FromMeta for Parameters { fn from_meta(meta: &Meta) -> Result { let meta_list = meta_to_meta_list(meta)?; @@ -88,6 +112,7 @@ impl ToTokens for Parameters { let header_parameters = self.header_parameters.iter().map(|x| x.for_parameter()); let path_parameters = &self.path_parameters; let query_parameters = &self.query_parameters; + let cookie_parameters = &self.cookie_parameters; let ref_parameters = &self.ref_parameters; tokens.extend(quote! { parameters: { @@ -95,6 +120,7 @@ impl ToTokens for Parameters { #(v.push(okapi::openapi3::RefOr::Object(#header_parameters));)* #(v.push(okapi::openapi3::RefOr::Object(#path_parameters));)* #(v.push(okapi::openapi3::RefOr::Object(#query_parameters));)* + #(v.push(okapi::openapi3::RefOr::Object(#cookie_parameters));)* #(v.push(#ref_parameters);)* v }, diff --git a/okapi-operation-macro/src/operation/path.rs b/okapi-operation-macro/src/operation/path.rs index f83cdad..ed33369 100644 --- a/okapi-operation-macro/src/operation/path.rs +++ b/okapi-operation-macro/src/operation/path.rs @@ -20,6 +20,23 @@ pub(super) struct Path { // TODO: support content as well } +impl Path { + pub(super) fn name(&self) -> &str { + &self.name + } + + /// Build a path parameter inferred from a function argument. + pub(super) fn new_inferred(name: String, schema: syn::Path) -> Self { + Self { + name, + description: None, + deprecated: false, + style: None, + schema, + } + } +} + impl ToTokens for Path { fn to_tokens(&self, tokens: &mut TokenStream) { let name = &self.name; diff --git a/okapi-operation-macro/src/operation/path_inference.rs b/okapi-operation-macro/src/operation/path_inference.rs new file mode 100644 index 0000000..9dc9d9f --- /dev/null +++ b/okapi-operation-macro/src/operation/path_inference.rs @@ -0,0 +1,119 @@ +//! Infer path parameters from a function signature. +//! +//! Currently only the axum-style `Path<...>` extractor is recognized, behind +//! the `axum` feature. Two binding shapes are supported: +//! +//! - `Path(name): Path` — produces a single path parameter named `name` +//! with schema `T`. +//! - `Path((a, b, ...)): Path<(T1, T2, ...)>` — produces one parameter per +//! tuple position; the name comes from the binding, the schema from the +//! corresponding tuple element. +//! +//! Anything more complex (struct extractors, `_` bindings, references, etc.) +//! is silently skipped — callers fall back to declaring the parameters +//! explicitly via `parameters(path(...))`. + +#![cfg(feature = "axum")] + +use syn::{FnArg, GenericArgument, ItemFn, Pat, PathArguments, Type}; + +use super::path::Path; + +/// Walk the function signature and produce inferred path parameters. +pub(super) fn infer_path_parameters(item_fn: &ItemFn) -> Vec { + let mut result = Vec::new(); + for arg in &item_fn.sig.inputs { + let FnArg::Typed(pt) = arg else { continue }; + let Some(params) = extract_from_arg(&pt.pat, &pt.ty) else { + continue; + }; + result.extend(params); + } + result +} + +fn extract_from_arg(pat: &Pat, ty: &Type) -> Option> { + let inner_ty = unwrap_axum_path_type(ty)?; + let names = extract_names_from_path_pat(pat)?; + + match inner_ty { + Type::Tuple(tuple) if names.len() == tuple.elems.len() => { + let mut params = Vec::with_capacity(names.len()); + for (name, elem_ty) in names.into_iter().zip(tuple.elems.iter()) { + let schema = type_to_simple_path(elem_ty)?; + params.push(Path::new_inferred(name, schema)); + } + Some(params) + } + // Tuples with mismatched arity vs binding — skip rather than guess. + Type::Tuple(_) => None, + // Single non-tuple type with a single binding name. + single if names.len() == 1 => { + let schema = type_to_simple_path(single)?; + Some(vec![Path::new_inferred( + names.into_iter().next().unwrap(), + schema, + )]) + } + _ => None, + } +} + +/// If the type is `Path` (last segment ident is `Path` with one generic +/// argument), return the inner type. +fn unwrap_axum_path_type(ty: &Type) -> Option<&Type> { + let Type::Path(tp) = ty else { return None }; + let last = tp.path.segments.last()?; + if last.ident != "Path" { + return None; + } + let PathArguments::AngleBracketed(args) = &last.arguments else { + return None; + }; + if args.args.len() != 1 { + return None; + } + match args.args.first()? { + GenericArgument::Type(inner) => Some(inner), + _ => None, + } +} + +/// Pull binding names out of a `Path(...)` pattern. Recognizes: +/// `Path(ident)` → `[ident]` +/// `Path((a, b, ...))` → `[a, b, ...]` +/// Returns `None` for anything else, including patterns containing `_`. +fn extract_names_from_path_pat(pat: &Pat) -> Option> { + let Pat::TupleStruct(ts) = pat else { + return None; + }; + let last = ts.path.segments.last()?; + if last.ident != "Path" { + return None; + } + if ts.elems.len() != 1 { + return None; + } + match ts.elems.first()? { + Pat::Ident(pi) => Some(vec![pi.ident.to_string()]), + Pat::Tuple(pt) => pt + .elems + .iter() + .map(|e| match e { + Pat::Ident(pi) => Some(pi.ident.to_string()), + _ => None, + }) + .collect(), + _ => None, + } +} + +/// Accept simple type-path schemas (`String`, `u32`, `my::Type`, …) and reject +/// everything else (references, slices, nested tuples). The downstream code +/// expects a `syn::Path` because it splices the type into a turbofish. +fn type_to_simple_path(ty: &Type) -> Option { + match ty { + Type::Path(tp) if tp.qself.is_none() => Some(tp.path.clone()), + _ => None, + } +} diff --git a/okapi-operation/CHANGELOG.md b/okapi-operation/CHANGELOG.md index ab188a9..b9e8f16 100644 --- a/okapi-operation/CHANGELOG.md +++ b/okapi-operation/CHANGELOG.md @@ -8,6 +8,17 @@ This project follows the [Semantic Versioning standard](https://semver.org/). ### Added - Now you can enable inferring `operation_id` from the function name, using `OpenApiBuilder.infer_operation_id()`. +- Path parameters are now inferred from the function signature when the `axum` + feature is enabled (`Path(name): Path` and + `Path((a, b)): Path<(T1, T2)>`). Explicit `parameters(path(...))` declarations + with the same name take precedence. + +### Fixed + +- `parameters(cookie(...))` is now actually emitted into the generated + operation, with `location` set to `"cookie"` per OpenAPI 3.0. Previously + cookie entries didn't compile, and even if they did they were silently + dropped from the spec. ## [0.3.0] - 2025-06-13 diff --git a/okapi-operation/tests/axum_integration.rs b/okapi-operation/tests/axum_integration.rs index a0b1efe..2f4a95d 100644 --- a/okapi-operation/tests/axum_integration.rs +++ b/okapi-operation/tests/axum_integration.rs @@ -132,3 +132,210 @@ mod openapi_handler { let _ = Router::<()>::new().route("/", get(openapi_service!(service))); } } + +#[cfg(feature = "axum")] +mod path_inference { + use axum::extract::Path; + use okapi::openapi3::{ParameterValue, RefOr}; + use okapi_operation::{ + axum_integration::{Router, get, post}, + oh, openapi, + }; + + fn parameters( + route: &str, + method: &str, + op: okapi::openapi3::Operation, + ) -> Vec { + let _ = (route, method); + op.parameters + .into_iter() + .map(|p| match p { + RefOr::Object(obj) => obj, + RefOr::Ref(_) => panic!("unexpected ref parameter"), + }) + .collect() + } + + fn get_op(schema: &okapi::openapi3::OpenApi, route: &str) -> okapi::openapi3::Operation { + schema.paths[route] + .clone() + .get + .expect("GET should be present") + } + + fn post_op(schema: &okapi::openapi3::OpenApi, route: &str) -> okapi::openapi3::Operation { + schema.paths[route] + .clone() + .post + .expect("POST should be present") + } + + fn assert_path_param(p: &okapi::openapi3::Parameter, name: &str) { + assert_eq!(p.name, name, "param name mismatch"); + assert_eq!(p.location, "path", "param location mismatch"); + assert!(p.required, "path params must be required"); + assert!( + matches!(p.value, ParameterValue::Schema { .. }), + "expected schema parameter value" + ); + } + + #[test] + fn infers_single_path_parameter() { + #[openapi] + async fn handle(Path(system): Path) { + let _ = system; + } + + let schema = Router::<()>::new() + .route("/api/{system}", get(oh!(handle))) + .generate_openapi_builder() + .build() + .expect("schema generation shouldn't fail"); + + let params = parameters("/api/{system}", "GET", get_op(&schema, "/api/{system}")); + assert_eq!(params.len(), 1); + assert_path_param(¶ms[0], "system"); + } + + #[test] + fn infers_tuple_path_parameters_in_order() { + #[openapi] + async fn abort_backup(Path((system, backup_name)): Path<(String, String)>) { + let _ = (system, backup_name); + } + + let schema = Router::<()>::new() + .route( + "/api/system/{system}/backup/abort/{backup_name}", + post(oh!(abort_backup)), + ) + .generate_openapi_builder() + .build() + .expect("schema generation shouldn't fail"); + + let params = parameters( + "/api/system/{system}/backup/abort/{backup_name}", + "POST", + post_op(&schema, "/api/system/{system}/backup/abort/{backup_name}"), + ); + assert_eq!(params.len(), 2, "two path params expected"); + assert_path_param(¶ms[0], "system"); + assert_path_param(¶ms[1], "backup_name"); + } + + #[test] + fn explicit_declaration_wins_over_inferred() { + // Explicit `description` should survive — inference must not overwrite + // a parameter already declared by name. + #[openapi(parameters(path( + name = "system", + description = "system id", + schema = "String" + )))] + async fn handle(Path(system): Path) { + let _ = system; + } + + let schema = Router::<()>::new() + .route("/api/{system}", get(oh!(handle))) + .generate_openapi_builder() + .build() + .expect("schema generation shouldn't fail"); + + let params = parameters("/api/{system}", "GET", get_op(&schema, "/api/{system}")); + assert_eq!(params.len(), 1, "no duplicate from inference"); + assert_eq!(params[0].name, "system"); + assert_eq!(params[0].description.as_deref(), Some("system id")); + } + + #[test] + fn unsupported_patterns_are_skipped() { + // Wildcard binding cannot be inferred — the user is expected to declare + // the parameter explicitly. We just verify the macro still compiles + // and produces an operation with no inferred params. + #[openapi] + async fn handle(Path(_): Path) {} + + let schema = Router::<()>::new() + .route("/api/{system}", get(oh!(handle))) + .generate_openapi_builder() + .build() + .expect("schema generation shouldn't fail"); + + let op = get_op(&schema, "/api/{system}"); + assert!(op.parameters.is_empty(), "no params should be inferred"); + } +} + +#[cfg(feature = "axum")] +mod cookie_parameters { + use okapi::openapi3::{ParameterValue, RefOr}; + use okapi_operation::{ + axum_integration::{Router, get}, + oh, openapi, + }; + + fn get_parameters( + schema: &okapi::openapi3::OpenApi, + route: &str, + ) -> Vec { + schema.paths[route] + .clone() + .get + .expect("GET should be present") + .parameters + .into_iter() + .map(|p| match p { + RefOr::Object(obj) => obj, + RefOr::Ref(_) => panic!("unexpected ref parameter"), + }) + .collect() + } + + #[test] + fn cookie_parameter_is_emitted() { + #[openapi(parameters(cookie(name = "session", required = true, schema = "String")))] + async fn handle() {} + + let schema = Router::<()>::new() + .route("/", get(oh!(handle))) + .generate_openapi_builder() + .build() + .expect("schema generation shouldn't fail"); + + let params = get_parameters(&schema, "/"); + assert_eq!(params.len(), 1, "cookie parameter must be emitted"); + let p = ¶ms[0]; + assert_eq!(p.name, "session"); + assert_eq!(p.location, "cookie", "OpenAPI requires lowercase 'cookie'"); + assert!(p.required); + assert!(matches!(p.value, ParameterValue::Schema { .. })); + } + + #[test] + fn cookie_mixed_with_other_parameter_kinds() { + // Verify cookie params don't collide with header/path/query when + // declared together on the same operation. + #[openapi(parameters( + header(name = "x-trace", schema = "String"), + query(name = "limit", schema = "u32"), + cookie(name = "session", schema = "String"), + ))] + async fn handle() {} + + let schema = Router::<()>::new() + .route("/", get(oh!(handle))) + .generate_openapi_builder() + .build() + .expect("schema generation shouldn't fail"); + + let params = get_parameters(&schema, "/"); + assert_eq!(params.len(), 3); + let locations: Vec<&str> = params.iter().map(|p| p.location.as_str()).collect(); + assert!(locations.contains(&"header")); + assert!(locations.contains(&"query")); + assert!(locations.contains(&"cookie")); + } +} From 77e9cc96a9bd176305b9e839db3771bae33df814 Mon Sep 17 00:00:00 2001 From: oriontvv Date: Sat, 23 May 2026 15:22:27 +0300 Subject: [PATCH 2/2] Fix merge with master --- okapi-operation-macro/src/operation/cookie.rs | 2 +- okapi-operation-macro/src/operation/path.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/okapi-operation-macro/src/operation/cookie.rs b/okapi-operation-macro/src/operation/cookie.rs index c5cac65..6c59d90 100644 --- a/okapi-operation-macro/src/operation/cookie.rs +++ b/okapi-operation-macro/src/operation/cookie.rs @@ -56,7 +56,7 @@ impl ToTokens for Cookie { let allow_reserved = false; quote! { okapi::openapi3::ParameterValue::Schema { - style: #style, + style: Some(#style), explode: #explode, allow_reserved: #allow_reserved, schema: components.schema_for::<#ty>(), diff --git a/okapi-operation-macro/src/operation/path.rs b/okapi-operation-macro/src/operation/path.rs index e672ca4..c2ae349 100644 --- a/okapi-operation-macro/src/operation/path.rs +++ b/okapi-operation-macro/src/operation/path.rs @@ -34,6 +34,7 @@ impl Path { deprecated: false, style: None, schema, + content: None, } } }