diff --git a/gotcha/tests/test_path_params.rs b/gotcha/tests/test_path_params.rs index afa1c41..656eaea 100644 --- a/gotcha/tests/test_path_params.rs +++ b/gotcha/tests/test_path_params.rs @@ -48,6 +48,58 @@ fn test_path_tuple_parameter() { } } +#[cfg(feature = "openapi")] +mod path_struct { + use either::Either; + use gotcha::{ParameterProvider, Path, Schematic}; + use oas::{Parameter, ParameterIn, Referenceable}; + + // Only the derived Schematic impl is exercised; the fields are never read directly. + #[allow(dead_code)] + #[derive(Schematic)] + struct ConnectionPath { + user_id: String, + connection_id: String, + } + + fn assert_per_field_params(params: &[Parameter]) { + assert_eq!(params.len(), 2, "each struct field should become one parameter"); + for (param, name) in params.iter().zip(["user_id", "connection_id"]) { + assert_eq!(param.name, name); + assert!(matches!(param._in, ParameterIn::Path)); + assert_eq!(param.required, Some(true)); + let Some(Referenceable::Data(schema)) = ¶m.schema else { + panic!("parameter '{name}' should carry an inline schema"); + }; + assert_eq!(schema._type.as_deref(), Some("string"), "parameter '{name}' should have the field's type"); + assert!(!schema.extras.contains_key("$ref"), "parameter '{name}' must not be a $ref to the whole struct"); + } + } + + #[test] + fn struct_fields_become_parameters() { + let url = "/users/{user_id}/connections/{connection_id}".to_string(); + let result = as ParameterProvider>::generate(url); + match result { + Either::Left(params) => assert_per_field_params(¶ms), + Either::Right(_) => panic!("Path should generate parameters, not a request body"), + } + } + + #[test] + fn struct_fields_become_parameters_inside_a_collection_scope() { + // Spec assembly generates every operation inside a collection scope, where a derived + // struct's generate_schema() returns a bare `$ref`. Parameters must still come out + // per-field — not one parameter holding the whole object's `$ref`, dropping the rest. + let url = "/users/{user_id}/connections/{connection_id}".to_string(); + let (result, _schemas) = gotcha_core::registry::collect(|| as ParameterProvider>::generate(url)); + match result { + Either::Left(params) => assert_per_field_params(¶ms), + Either::Right(_) => panic!("Path should generate parameters, not a request body"), + } + } +} + #[cfg(feature = "openapi")] #[test] fn test_multiple_path_params() { diff --git a/gotcha_core/src/parameter.rs b/gotcha_core/src/parameter.rs index 08ba9e0..05d23a9 100644 --- a/gotcha_core/src/parameter.rs +++ b/gotcha_core/src/parameter.rs @@ -84,12 +84,29 @@ impl ParameterProvider for Path<(T1, T2)> { impl ParameterProvider for Path { fn generate(url: String) -> Either, RequestBody> { + // Case 1: a struct extractor — each field becomes one path parameter. Read the fields + // directly (as `Query` does) instead of scraping `properties` out of the generated + // schema: during spec assembly a collection scope is active, so a derived struct's + // `generate_schema()` returns a bare `$ref` carrying no properties at all. + let fields = T::fields(); + if !fields.is_empty() { + return Either::Left( + fields + .into_iter() + .map(|(name, schema)| { + let desc = schema.schema.description.clone(); + build_param(name.to_string(), ParameterIn::Path, schema.required, schema.schema, desc) + }) + .collect(), + ); + } + let mut ret = vec![]; let mut schema = T::generate_schema(); - // Check if this is a struct with properties or a simple type + // Check if this is a hand-written impl exposing properties or a simple type if let Some(mut properties) = schema.schema.extras.remove("properties") { - // Case 1: Struct with properties - each property becomes a path parameter + // Case 2: object schema with properties - each property becomes a path parameter if let Some(properties) = properties.as_object_mut() { properties.iter_mut().for_each(|(key, value)| { let schema = serde_json::from_value(value.clone()).unwrap(); @@ -98,7 +115,7 @@ impl ParameterProvider for Path { }) } } else { - // Case 2: Simple type like Uuid - extract parameter name from URL + // Case 3: Simple type like Uuid - extract parameter name from URL // Since axum 0.8 a captured segment is written `{name}`, matching OpenAPI's own syntax. let pattern = regex::Regex::new(r"\{([^}]+)\}").unwrap(); let param_names_in_path: Vec = pattern.captures_iter(&url).map(|digits| digits.get(1).unwrap().as_str().to_string()).collect();