Skip to main content

mockforge_bench/
request_gen.rs

1//! Request template generation from OpenAPI operations
2
3use crate::error::Result;
4use crate::param_overrides::OperationOverrides;
5use crate::spec_parser::ApiOperation;
6use openapiv3::{
7    MediaType, Parameter, ParameterData, ParameterSchemaOrContent, ReferenceOr, RequestBody,
8    Schema, SchemaKind, Type,
9};
10use serde_json::{json, Value};
11use std::collections::HashMap;
12
13/// A request template for load testing
14#[derive(Debug, Clone)]
15pub struct RequestTemplate {
16    pub operation: ApiOperation,
17    pub path_params: HashMap<String, String>,
18    pub query_params: HashMap<String, String>,
19    pub headers: HashMap<String, String>,
20    pub body: Option<Value>,
21}
22
23impl RequestTemplate {
24    /// Generate the full URL path with parameters substituted
25    pub fn generate_path(&self) -> String {
26        let mut path = self.operation.path.clone();
27
28        for (key, value) in &self.path_params {
29            path = path.replace(&format!("{{{}}}", key), value);
30        }
31
32        if !self.query_params.is_empty() {
33            let query_string: Vec<String> =
34                self.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
35            path = format!("{}?{}", path, query_string.join("&"));
36        }
37
38        path
39    }
40
41    /// Get all headers including content-type
42    pub fn get_headers(&self) -> HashMap<String, String> {
43        let mut headers = self.headers.clone();
44
45        if self.body.is_some() {
46            headers
47                .entry("Content-Type".to_string())
48                .or_insert_with(|| "application/json".to_string());
49        }
50
51        headers
52    }
53}
54
55/// Request template generator
56pub struct RequestGenerator;
57
58impl RequestGenerator {
59    /// Generate a request template from an API operation
60    pub fn generate_template(operation: &ApiOperation) -> Result<RequestTemplate> {
61        Self::generate_template_with_overrides(operation, None)
62    }
63
64    /// Generate a request template with optional parameter overrides
65    ///
66    /// When overrides are provided, they take precedence over auto-generated values.
67    /// This allows users to provide realistic test data instead of placeholder values.
68    pub fn generate_template_with_overrides(
69        operation: &ApiOperation,
70        overrides: Option<&OperationOverrides>,
71    ) -> Result<RequestTemplate> {
72        let mut template = RequestTemplate {
73            operation: operation.clone(),
74            path_params: HashMap::new(),
75            query_params: HashMap::new(),
76            headers: HashMap::new(),
77            body: None,
78        };
79
80        // Extract parameters from OpenAPI spec
81        for param_ref in &operation.operation.parameters {
82            if let ReferenceOr::Item(param) = param_ref {
83                Self::process_parameter_with_overrides(param, &mut template, overrides)?;
84            }
85        }
86
87        // Apply any additional overridden parameters not in the spec
88        if let Some(ovr) = overrides {
89            // Add overridden path params that weren't in the spec
90            for (name, value) in &ovr.path_params {
91                template.path_params.entry(name.clone()).or_insert_with(|| value.clone());
92            }
93            // Add overridden query params that weren't in the spec
94            for (name, value) in &ovr.query_params {
95                template.query_params.entry(name.clone()).or_insert_with(|| value.clone());
96            }
97            // Add overridden headers that weren't in the spec
98            for (name, value) in &ovr.headers {
99                template.headers.entry(name.clone()).or_insert_with(|| value.clone());
100            }
101        }
102
103        // Extract request body (override takes precedence)
104        if let Some(ovr) = overrides {
105            if let Some(body) = ovr.get_body() {
106                template.body = Some(body.clone());
107            } else if let Some(ReferenceOr::Item(request_body)) = &operation.operation.request_body
108            {
109                template.body = Self::generate_body(request_body)?;
110            }
111        } else if let Some(ReferenceOr::Item(request_body)) = &operation.operation.request_body {
112            template.body = Self::generate_body(request_body)?;
113        }
114
115        Ok(template)
116    }
117
118    /// Process a parameter with optional overrides
119    fn process_parameter_with_overrides(
120        param: &Parameter,
121        template: &mut RequestTemplate,
122        overrides: Option<&OperationOverrides>,
123    ) -> Result<()> {
124        let (param_type, param_data) = match param {
125            Parameter::Query { parameter_data, .. } => ("query", parameter_data),
126            Parameter::Path { parameter_data, .. } => ("path", parameter_data),
127            Parameter::Header { parameter_data, .. } => ("header", parameter_data),
128            Parameter::Cookie { parameter_data, .. } => ("cookie", parameter_data),
129        };
130
131        // Check for override first, then fall back to generated value
132        let value = if let Some(ovr) = overrides {
133            match param_type {
134                "path" => ovr.get_path_param(&param_data.name).cloned(),
135                "query" => ovr.get_query_param(&param_data.name).cloned(),
136                "header" => ovr.get_header(&param_data.name).cloned(),
137                _ => None,
138            }
139        } else {
140            None
141        }
142        .unwrap_or_else(|| Self::generate_param_value(param_data).unwrap_or_default());
143
144        match param_type {
145            "query" => {
146                template.query_params.insert(param_data.name.clone(), value);
147            }
148            "path" => {
149                template.path_params.insert(param_data.name.clone(), value);
150            }
151            "header" => {
152                // Issue #79 (f) — S3 (and others) list Content-Length / Host as
153                // header parameters. Filling those from the schema invents
154                // `Content-Length: 42` on a 0-byte body. k6 then drops the
155                // request: the declared length does not match the body.
156                // Skip hop-by-hop / transport-owned names unless the user
157                // overrode them on purpose (WAF CL-mismatch cases).
158                let from_override =
159                    overrides.and_then(|o| o.get_header(&param_data.name)).is_some();
160                if Self::is_transport_owned_header(&param_data.name) && !from_override {
161                    // Leave it to k6 / the HTTP client.
162                } else {
163                    template.headers.insert(param_data.name.clone(), value);
164                }
165            }
166            "cookie" => {
167                // Append cookie to existing Cookie header or create new one
168                let cookie_pair = format!("{}={}", param_data.name, value);
169                template
170                    .headers
171                    .entry("Cookie".to_string())
172                    .and_modify(|existing| {
173                        existing.push_str("; ");
174                        existing.push_str(&cookie_pair);
175                    })
176                    .or_insert(cookie_pair);
177            }
178            _ => {}
179        }
180
181        Ok(())
182    }
183
184    /// Headers the HTTP client computes. Auto-filling them from an OpenAPI
185    /// schema is how Srikanth's Amazon S3 spec produced `Content-Length: 42`
186    /// on every empty POST and sent no useful traffic (#79 (f)).
187    pub(crate) fn is_transport_owned_header(name: &str) -> bool {
188        matches!(
189            name.to_ascii_lowercase().as_str(),
190            "content-length"
191                | "transfer-encoding"
192                | "host"
193                | "connection"
194                | "keep-alive"
195                | "te"
196                | "trailer"
197                | "upgrade"
198                | "proxy-connection"
199        )
200    }
201
202    /// Generate a value for a parameter
203    fn generate_param_value(param_data: &ParameterData) -> Result<String> {
204        // Try to use example first
205        if let Some(example) = &param_data.example {
206            return Ok(example.to_string().trim_matches('"').to_string());
207        }
208
209        // Generate from schema
210        if let ParameterSchemaOrContent::Schema(ReferenceOr::Item(schema)) = &param_data.format {
211            return Ok(Self::generate_value_from_schema(schema));
212        }
213
214        // Default value based on parameter name
215        Ok(Self::default_param_value(&param_data.name))
216    }
217
218    /// Generate a default value based on parameter name
219    fn default_param_value(name: &str) -> String {
220        match name.to_lowercase().as_str() {
221            "id" => "1".to_string(),
222            "limit" => "10".to_string(),
223            "offset" => "0".to_string(),
224            "page" => "1".to_string(),
225            "sort" => "name".to_string(),
226            _ => "test-value".to_string(),
227        }
228    }
229
230    /// Generate a request body from a RequestBody definition
231    fn generate_body(request_body: &RequestBody) -> Result<Option<Value>> {
232        // Look for application/json content
233        if let Some(content) = request_body.content.get("application/json") {
234            return Ok(Some(Self::generate_json_body(content)));
235        }
236
237        Ok(None)
238    }
239
240    /// Generate JSON body from media type
241    fn generate_json_body(media_type: &MediaType) -> Value {
242        // Try to use example first
243        if let Some(example) = &media_type.example {
244            return example.clone();
245        }
246
247        // Generate from schema
248        if let Some(ReferenceOr::Item(schema)) = &media_type.schema {
249            return Self::generate_json_from_schema(schema);
250        }
251
252        json!({})
253    }
254
255    /// Generate JSON from schema
256    fn generate_json_from_schema(schema: &Schema) -> Value {
257        match &schema.schema_kind {
258            SchemaKind::Type(Type::Object(obj)) => {
259                let mut map = serde_json::Map::new();
260
261                for (key, schema_ref) in &obj.properties {
262                    if let ReferenceOr::Item(prop_schema) = schema_ref {
263                        map.insert(key.clone(), Self::generate_json_from_schema(prop_schema));
264                    }
265                }
266
267                Value::Object(map)
268            }
269            SchemaKind::Type(Type::Array(arr)) => {
270                if let Some(ReferenceOr::Item(item_schema)) = &arr.items {
271                    return json!([Self::generate_json_from_schema(item_schema)]);
272                }
273                json!([])
274            }
275            SchemaKind::Type(Type::String(_)) => Self::generate_string_value(schema),
276            SchemaKind::Type(Type::Number(n)) => n
277                .enumeration
278                .iter()
279                .flatten()
280                .next()
281                .map(|v| json!(v))
282                .unwrap_or_else(|| json!(42.0)),
283            SchemaKind::Type(Type::Integer(i)) => i
284                .enumeration
285                .iter()
286                .flatten()
287                .next()
288                .map(|v| json!(v))
289                .unwrap_or_else(|| json!(42)),
290            SchemaKind::Type(Type::Boolean(_)) => json!(true),
291            _ => json!(null),
292        }
293    }
294
295    /// Generate a string value from schema
296    fn generate_string_value(schema: &Schema) -> Value {
297        // Use example if available
298        if let Some(example) = &schema.schema_data.example {
299            return example.clone();
300        }
301        // Round 51 (#79) — respect an enum so the positive body is spec-VALID
302        // (Srikanth on 0.3.196: `billingType` was filled with the invalid
303        // literal "test-string", tripping a body enum violation on every
304        // probe). Negative body probes still override the value they attack.
305        if let SchemaKind::Type(Type::String(s)) = &schema.schema_kind {
306            if let Some(first) = s.enumeration.iter().flatten().next() {
307                return json!(first);
308            }
309        }
310
311        json!("test-string")
312    }
313
314    /// Generate a value from schema (for parameters)
315    fn generate_value_from_schema(schema: &Schema) -> String {
316        match &schema.schema_kind {
317            SchemaKind::Type(Type::String(_)) => "test-value".to_string(),
318            SchemaKind::Type(Type::Number(_)) => "42.0".to_string(),
319            SchemaKind::Type(Type::Integer(_)) => "42".to_string(),
320            SchemaKind::Type(Type::Boolean(_)) => "true".to_string(),
321            _ => "test-value".to_string(),
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use openapiv3::Operation;
330
331    // Round 51 (#79) — a string property with an enum must generate a VALID
332    // member, not the invalid literal "test-string".
333    #[test]
334    fn generate_string_value_uses_enum_member() {
335        use openapiv3::{Schema, SchemaData, SchemaKind, StringType, Type};
336        let st = StringType {
337            enumeration: vec![Some("EVALUATION".into()), Some("PAYG".into())],
338            ..Default::default()
339        };
340        let schema = Schema {
341            schema_data: SchemaData::default(),
342            schema_kind: SchemaKind::Type(Type::String(st)),
343        };
344        assert_eq!(RequestGenerator::generate_string_value(&schema), json!("EVALUATION"));
345
346        // No enum -> the generic filler is fine.
347        let plain = Schema {
348            schema_data: SchemaData::default(),
349            schema_kind: SchemaKind::Type(Type::String(StringType::default())),
350        };
351        assert_eq!(RequestGenerator::generate_string_value(&plain), json!("test-string"));
352    }
353
354    #[test]
355    fn test_generate_path() {
356        let op = ApiOperation {
357            method: "get".to_string(),
358            path: "/users/{id}".to_string(),
359            operation: Operation::default(),
360            operation_id: None,
361        };
362
363        let mut template = RequestTemplate {
364            operation: op,
365            path_params: HashMap::new(),
366            query_params: HashMap::new(),
367            headers: HashMap::new(),
368            body: None,
369        };
370
371        template.path_params.insert("id".to_string(), "123".to_string());
372        template.query_params.insert("limit".to_string(), "10".to_string());
373
374        let path = template.generate_path();
375        assert_eq!(path, "/users/123?limit=10");
376    }
377
378    #[test]
379    fn test_default_param_value() {
380        assert_eq!(RequestGenerator::default_param_value("id"), "1");
381        assert_eq!(RequestGenerator::default_param_value("limit"), "10");
382        assert_eq!(RequestGenerator::default_param_value("unknown"), "test-value");
383    }
384
385    /// #79 (f): an integer `Content-Length` header param must not be
386    /// invented as `"42"`. k6 owns that header.
387    #[test]
388    fn spec_content_length_header_is_not_invented() {
389        use openapiv3::{HeaderStyle, IntegerType, ParameterData, Schema, SchemaData, SchemaKind};
390
391        let mut operation = Operation::default();
392        operation.parameters.push(ReferenceOr::Item(Parameter::Header {
393            parameter_data: ParameterData {
394                name: "Content-Length".to_string(),
395                description: None,
396                required: false,
397                deprecated: None,
398                format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(Schema {
399                    schema_data: SchemaData::default(),
400                    schema_kind: SchemaKind::Type(Type::Integer(IntegerType::default())),
401                })),
402                example: None,
403                examples: Default::default(),
404                explode: None,
405                extensions: Default::default(),
406            },
407            style: HeaderStyle::Simple,
408        }));
409        // A normal header still comes through so we know the loop ran.
410        operation.parameters.push(ReferenceOr::Item(Parameter::Header {
411            parameter_data: ParameterData {
412                name: "x-amz-request-route".to_string(),
413                description: None,
414                required: false,
415                deprecated: None,
416                format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(Schema {
417                    schema_data: SchemaData::default(),
418                    schema_kind: SchemaKind::Type(Type::String(openapiv3::StringType::default())),
419                })),
420                example: None,
421                examples: Default::default(),
422                explode: None,
423                extensions: Default::default(),
424            },
425            style: HeaderStyle::Simple,
426        }));
427
428        let api_op = ApiOperation {
429            method: "post".to_string(),
430            path: "/WriteGetObjectResponse".to_string(),
431            operation,
432            operation_id: Some("WriteGetObjectResponse".to_string()),
433        };
434        let template = RequestGenerator::generate_template(&api_op).expect("template");
435        let headers = template.get_headers();
436        assert!(
437            !headers.keys().any(|k| k.eq_ignore_ascii_case("content-length")),
438            "invented Content-Length={:?} would make k6 drop empty-body POSTs",
439            headers.get("Content-Length")
440        );
441        assert_eq!(headers.get("x-amz-request-route").map(String::as_str), Some("test-value"));
442        assert!(RequestGenerator::is_transport_owned_header("Content-Length"));
443        assert!(RequestGenerator::is_transport_owned_header("HOST"));
444        assert!(!RequestGenerator::is_transport_owned_header("x-amz-request-route"));
445    }
446}