spikard_http/openapi/
parameter_extraction.rs

1//! Parameter extraction from routes and schemas for OpenAPI generation
2
3use utoipa::openapi::RefOr;
4use utoipa::openapi::path::Parameter;
5use utoipa::openapi::path::{ParameterBuilder, ParameterIn};
6
7/// Extract parameters from JSON Schema parameter_schema
8pub fn extract_parameters_from_schema(
9    param_schema: &serde_json::Value,
10    route_path: &str,
11) -> Result<Vec<RefOr<Parameter>>, String> {
12    let mut parameters = Vec::new();
13
14    let path_params = extract_path_param_names(route_path);
15
16    let properties = param_schema
17        .get("properties")
18        .and_then(|p| p.as_object())
19        .ok_or_else(|| "Parameter schema missing 'properties' field".to_string())?;
20
21    let required = param_schema
22        .get("required")
23        .and_then(|r| r.as_array())
24        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
25        .unwrap_or_default();
26
27    for (name, schema) in properties {
28        let is_required = required.contains(&name.as_str());
29        let param_in = if path_params.contains(&name.as_str()) {
30            ParameterIn::Path
31        } else {
32            ParameterIn::Query
33        };
34
35        let openapi_schema = crate::openapi::schema_conversion::json_value_to_schema(schema)?;
36
37        let is_path_param = matches!(param_in, ParameterIn::Path);
38
39        let param = ParameterBuilder::new()
40            .name(name)
41            .parameter_in(param_in)
42            .required(if is_path_param || is_required {
43                utoipa::openapi::Required::True
44            } else {
45                utoipa::openapi::Required::False
46            })
47            .schema(Some(openapi_schema))
48            .build();
49
50        parameters.push(RefOr::T(param));
51    }
52
53    Ok(parameters)
54}
55
56/// Extract path parameter names from route pattern (e.g., "/users/{id}" -> ["id"])
57pub fn extract_path_param_names(route: &str) -> Vec<&str> {
58    route
59        .split('/')
60        .filter_map(|segment| {
61            if segment.starts_with('{') && segment.ends_with('}') {
62                Some(&segment[1..segment.len() - 1])
63            } else {
64                None
65            }
66        })
67        .collect()
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use serde_json::json;
74
75    #[test]
76    fn test_extract_path_param_names() {
77        let names = extract_path_param_names("/users/{id}/posts/{post_id}");
78        assert_eq!(names, vec!["id", "post_id"]);
79
80        let names = extract_path_param_names("/users");
81        assert_eq!(names, Vec::<&str>::new());
82
83        let names = extract_path_param_names("/users/{user_id}");
84        assert_eq!(names, vec!["user_id"]);
85    }
86
87    #[test]
88    fn test_extract_parameters_from_schema_path_params() {
89        let param_schema = json!({
90            "type": "object",
91            "properties": {
92                "user_id": { "type": "integer" },
93                "post_id": { "type": "integer" }
94            },
95            "required": ["user_id", "post_id"]
96        });
97
98        let result = extract_parameters_from_schema(&param_schema, "/users/{user_id}/posts/{post_id}");
99        assert!(result.is_ok());
100
101        let params = result.unwrap();
102        assert_eq!(params.len(), 2);
103
104        for param in params {
105            if let RefOr::T(p) = param {
106                assert!(matches!(p.parameter_in, ParameterIn::Path));
107                assert!(matches!(p.required, utoipa::openapi::Required::True));
108            }
109        }
110    }
111
112    #[test]
113    fn test_extract_parameters_from_schema_query_params() {
114        let param_schema = json!({
115            "type": "object",
116            "properties": {
117                "page": { "type": "integer" },
118                "limit": { "type": "integer" },
119                "search": { "type": "string" }
120            },
121            "required": ["page"]
122        });
123
124        let result = extract_parameters_from_schema(&param_schema, "/users");
125        assert!(result.is_ok());
126
127        let params = result.unwrap();
128        assert_eq!(params.len(), 3);
129
130        for param in &params {
131            if let RefOr::T(p) = param {
132                assert!(matches!(p.parameter_in, ParameterIn::Query));
133            }
134        }
135
136        for param in params {
137            if let RefOr::T(p) = param {
138                if p.name == "page" {
139                    assert!(matches!(p.required, utoipa::openapi::Required::True));
140                } else {
141                    assert!(matches!(p.required, utoipa::openapi::Required::False));
142                }
143            }
144        }
145    }
146
147    #[test]
148    fn test_extract_parameters_from_schema_mixed() {
149        let param_schema = json!({
150            "type": "object",
151            "properties": {
152                "user_id": { "type": "integer" },
153                "page": { "type": "integer" },
154                "limit": { "type": "integer" }
155            },
156            "required": ["user_id"]
157        });
158
159        let result = extract_parameters_from_schema(&param_schema, "/users/{user_id}");
160        assert!(result.is_ok());
161
162        let params = result.unwrap();
163        assert_eq!(params.len(), 3);
164
165        for param in params {
166            if let RefOr::T(p) = param {
167                if p.name == "user_id" {
168                    assert!(matches!(p.parameter_in, ParameterIn::Path));
169                    assert!(matches!(p.required, utoipa::openapi::Required::True));
170                } else {
171                    assert!(matches!(p.parameter_in, ParameterIn::Query));
172                    assert!(matches!(p.required, utoipa::openapi::Required::False));
173                }
174            }
175        }
176    }
177
178    #[test]
179    fn test_extract_parameters_error_on_missing_properties() {
180        let param_schema = json!({
181            "type": "object"
182        });
183
184        let result = extract_parameters_from_schema(&param_schema, "/users");
185        assert!(result.is_err());
186        if let Err(err) = result {
187            assert!(err.contains("properties"));
188        }
189    }
190
191    #[test]
192    fn test_extract_parameters_with_format_specifiers() {
193        let param_schema = json!({
194            "type": "object",
195            "properties": {
196                "user_id": { "type": "string", "format": "uuid" },
197                "created_at": { "type": "string", "format": "date-time" },
198                "birth_date": { "type": "string", "format": "date" },
199                "email": { "type": "string", "format": "email" },
200                "website": { "type": "string", "format": "uri" }
201            },
202            "required": ["user_id"]
203        });
204
205        let result = extract_parameters_from_schema(&param_schema, "/users");
206        assert!(result.is_ok());
207
208        let params: Vec<RefOr<Parameter>> = result.unwrap();
209        assert_eq!(params.len(), 5);
210
211        for param in params {
212            if let RefOr::T(p) = param {
213                assert!(matches!(p.parameter_in, ParameterIn::Query));
214                if p.name == "user_id" {
215                    assert!(matches!(p.required, utoipa::openapi::Required::True));
216                } else {
217                    assert!(matches!(p.required, utoipa::openapi::Required::False));
218                }
219            }
220        }
221    }
222
223    #[test]
224    fn test_extract_parameters_with_nullable_optional() {
225        let param_schema = json!({
226            "type": "object",
227            "properties": {
228                "search": { "type": "string" },
229                "filter": { "type": "string" }
230            },
231            "required": []
232        });
233
234        let result = extract_parameters_from_schema(&param_schema, "/items");
235        assert!(result.is_ok());
236
237        let params: Vec<RefOr<Parameter>> = result.unwrap();
238        assert_eq!(params.len(), 2);
239
240        for param in params {
241            if let RefOr::T(p) = param {
242                assert!(matches!(p.required, utoipa::openapi::Required::False));
243            }
244        }
245    }
246
247    #[test]
248    fn test_extract_parameters_array_parameter() {
249        let param_schema = json!({
250            "type": "object",
251            "properties": {
252                "tags": {
253                    "type": "array",
254                    "items": { "type": "string" }
255                },
256                "ids": {
257                    "type": "array",
258                    "items": { "type": "integer" }
259                }
260            },
261            "required": ["tags"]
262        });
263
264        let result = extract_parameters_from_schema(&param_schema, "/search");
265        assert!(result.is_ok());
266
267        let params: Vec<RefOr<Parameter>> = result.unwrap();
268        assert_eq!(params.len(), 2);
269
270        for param in params {
271            if let RefOr::T(p) = param {
272                if p.name == "tags" {
273                    assert!(matches!(p.required, utoipa::openapi::Required::True));
274                } else if p.name == "ids" {
275                    assert!(matches!(p.required, utoipa::openapi::Required::False));
276                }
277            }
278        }
279    }
280
281    #[test]
282    fn test_extract_parameters_empty_properties() {
283        let param_schema = json!({
284            "type": "object",
285            "properties": {},
286            "required": []
287        });
288
289        let result = extract_parameters_from_schema(&param_schema, "/items");
290        assert!(result.is_ok());
291
292        let params: Vec<RefOr<Parameter>> = result.unwrap();
293        assert_eq!(params.len(), 0);
294    }
295
296    #[test]
297    fn test_extract_parameters_with_multiple_path_params() {
298        let param_schema = json!({
299            "type": "object",
300            "properties": {
301                "org_id": { "type": "string" },
302                "team_id": { "type": "string" },
303                "member_id": { "type": "string" },
304                "page": { "type": "integer" }
305            },
306            "required": ["org_id", "team_id", "member_id"]
307        });
308
309        let result =
310            extract_parameters_from_schema(&param_schema, "/orgs/{org_id}/teams/{team_id}/members/{member_id}");
311        assert!(result.is_ok());
312
313        let params: Vec<RefOr<Parameter>> = result.unwrap();
314        assert_eq!(params.len(), 4);
315
316        let mut path_count: i32 = 0;
317        let mut query_count: i32 = 0;
318
319        for param in params {
320            if let RefOr::T(p) = param {
321                if matches!(p.parameter_in, ParameterIn::Path) {
322                    path_count += 1;
323                    assert!(matches!(p.required, utoipa::openapi::Required::True));
324                } else {
325                    query_count += 1;
326                }
327            }
328        }
329
330        assert_eq!(path_count, 3);
331        assert_eq!(query_count, 1);
332    }
333
334    #[test]
335    fn test_extract_parameters_with_numeric_types() {
336        let param_schema = json!({
337            "type": "object",
338            "properties": {
339                "count": { "type": "integer" },
340                "score": { "type": "number" },
341                "active": { "type": "boolean" }
342            },
343            "required": ["count"]
344        });
345
346        let result = extract_parameters_from_schema(&param_schema, "/stats");
347        assert!(result.is_ok());
348
349        let params: Vec<RefOr<Parameter>> = result.unwrap();
350        assert_eq!(params.len(), 3);
351
352        for param in params {
353            if let RefOr::T(p) = param {
354                assert!(matches!(p.parameter_in, ParameterIn::Query));
355                if p.name == "count" {
356                    assert!(matches!(p.required, utoipa::openapi::Required::True));
357                }
358            }
359        }
360    }
361
362    #[test]
363    fn test_extract_parameters_required_field_parsing() {
364        let param_schema = json!({
365            "type": "object",
366            "properties": {
367                "id": { "type": "integer" },
368                "name": { "type": "string" },
369                "email": { "type": "string" },
370                "age": { "type": "integer" }
371            },
372            "required": ["id", "name"]
373        });
374
375        let result = extract_parameters_from_schema(&param_schema, "/items");
376        assert!(result.is_ok());
377
378        let params: Vec<RefOr<Parameter>> = result.unwrap();
379        assert_eq!(params.len(), 4);
380
381        let required_names: Vec<&str> = vec!["id", "name"];
382
383        for param in params {
384            if let RefOr::T(p) = param {
385                if required_names.contains(&p.name.as_str()) {
386                    assert!(matches!(p.required, utoipa::openapi::Required::True));
387                } else {
388                    assert!(matches!(p.required, utoipa::openapi::Required::False));
389                }
390            }
391        }
392    }
393
394    #[test]
395    fn test_extract_parameters_single_path_param_override_required() {
396        let param_schema = json!({
397            "type": "object",
398            "properties": {
399                "id": { "type": "integer" },
400                "query": { "type": "string" }
401            },
402            "required": ["query"]
403        });
404
405        let result = extract_parameters_from_schema(&param_schema, "/items/{id}");
406        assert!(result.is_ok());
407
408        let params: Vec<RefOr<Parameter>> = result.unwrap();
409        assert_eq!(params.len(), 2);
410
411        for param in params {
412            if let RefOr::T(p) = param {
413                if p.name == "id" {
414                    assert!(matches!(p.parameter_in, ParameterIn::Path));
415                    assert!(matches!(p.required, utoipa::openapi::Required::True));
416                } else if p.name == "query" {
417                    assert!(matches!(p.parameter_in, ParameterIn::Query));
418                    assert!(matches!(p.required, utoipa::openapi::Required::True));
419                }
420            }
421        }
422    }
423
424    #[test]
425    fn test_extract_parameters_nested_object_schema() {
426        let param_schema = json!({
427            "type": "object",
428            "properties": {
429                "filter": {
430                    "type": "object",
431                    "properties": {
432                        "status": { "type": "string" },
433                        "priority": { "type": "integer" }
434                    },
435                    "required": ["status"]
436                }
437            },
438            "required": ["filter"]
439        });
440
441        let result = extract_parameters_from_schema(&param_schema, "/tasks");
442        assert!(result.is_ok());
443
444        let params: Vec<RefOr<Parameter>> = result.unwrap();
445        assert_eq!(params.len(), 1);
446
447        if let Some(RefOr::T(p)) = params.first() {
448            assert_eq!(p.name, "filter");
449            assert!(matches!(p.parameter_in, ParameterIn::Query));
450            assert!(matches!(p.required, utoipa::openapi::Required::True));
451        }
452    }
453
454    #[test]
455    fn test_extract_parameters_with_special_characters_in_names() {
456        let param_schema = json!({
457            "type": "object",
458            "properties": {
459                "user_id": { "type": "string" },
460                "api_key": { "type": "string" },
461                "x_custom_header": { "type": "string" }
462            },
463            "required": ["user_id"]
464        });
465
466        let result = extract_parameters_from_schema(&param_schema, "/data");
467        assert!(result.is_ok());
468
469        let params: Vec<RefOr<Parameter>> = result.unwrap();
470        assert_eq!(params.len(), 3);
471
472        let param_names: Vec<String> = params
473            .iter()
474            .filter_map(|p| match p {
475                RefOr::T(param) => Some(param.name.clone()),
476                RefOr::Ref(_) => None,
477            })
478            .collect();
479
480        assert!(param_names.contains(&"user_id".to_string()));
481        assert!(param_names.contains(&"api_key".to_string()));
482        assert!(param_names.contains(&"x_custom_header".to_string()));
483    }
484
485    #[test]
486    fn test_extract_parameters_with_mismatched_required_field() {
487        let param_schema = json!({
488            "type": "object",
489            "properties": {
490                "id": { "type": "integer" },
491                "name": { "type": "string" }
492            },
493            "required": ["id", "nonexistent_field"]
494        });
495
496        let result = extract_parameters_from_schema(&param_schema, "/items");
497        assert!(result.is_ok());
498
499        let params: Vec<RefOr<Parameter>> = result.unwrap();
500        assert_eq!(params.len(), 2);
501
502        for param in params {
503            if let RefOr::T(p) = param {
504                if p.name == "id" {
505                    assert!(matches!(p.required, utoipa::openapi::Required::True));
506                }
507            }
508        }
509    }
510
511    #[test]
512    fn test_extract_path_param_names_with_special_segments() {
513        let names: Vec<&str> =
514            extract_path_param_names("/api/v1/users/{user_id}/posts/{post_id}/comments/{comment_id}");
515        assert_eq!(names, vec!["user_id", "post_id", "comment_id"]);
516    }
517
518    #[test]
519    fn test_extract_path_param_names_no_params() {
520        let names: Vec<&str> = extract_path_param_names("/api/users/list");
521        assert!(names.is_empty());
522    }
523
524    #[test]
525    fn test_extract_path_param_names_single_param_end() {
526        let names: Vec<&str> = extract_path_param_names("/resource/{id}");
527        assert_eq!(names, vec!["id"]);
528    }
529
530    #[test]
531    fn test_extract_path_param_names_numeric_param_names() {
532        let names: Vec<&str> = extract_path_param_names("/items/{id1}/sub/{id2}");
533        assert_eq!(names, vec!["id1", "id2"]);
534    }
535}