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