Skip to main content

mockforge_bench/conformance/
spec_driven.rs

1//! Spec-driven conformance testing
2//!
3//! Analyzes the user's OpenAPI spec to determine which features their API uses,
4//! then generates k6 conformance tests against their real endpoints.
5
6use super::generator::ConformanceConfig;
7use super::schema_validator::SchemaValidatorGenerator;
8use super::spec::ConformanceFeature;
9use crate::error::Result;
10use crate::request_gen::RequestGenerator;
11use crate::spec_parser::ApiOperation;
12use openapiv3::{
13    OpenAPI, Operation, Parameter, ParameterSchemaOrContent, ReferenceOr, RequestBody, Response,
14    Schema, SchemaKind, StringFormat, Type, VariantOrUnknownOrEmpty,
15};
16use std::collections::HashSet;
17
18/// Resolve `$ref` references against the OpenAPI components
19mod ref_resolver {
20    use super::*;
21
22    pub fn resolve_parameter<'a>(
23        param_ref: &'a ReferenceOr<Parameter>,
24        spec: &'a OpenAPI,
25    ) -> Option<&'a Parameter> {
26        match param_ref {
27            ReferenceOr::Item(param) => Some(param),
28            ReferenceOr::Reference { reference } => {
29                let name = reference.strip_prefix("#/components/parameters/")?;
30                let components = spec.components.as_ref()?;
31                match components.parameters.get(name)? {
32                    ReferenceOr::Item(param) => Some(param),
33                    ReferenceOr::Reference { .. } => None, // No recursive ref chasing
34                }
35            }
36        }
37    }
38
39    pub fn resolve_request_body<'a>(
40        body_ref: &'a ReferenceOr<RequestBody>,
41        spec: &'a OpenAPI,
42    ) -> Option<&'a RequestBody> {
43        match body_ref {
44            ReferenceOr::Item(body) => Some(body),
45            ReferenceOr::Reference { reference } => {
46                let name = reference.strip_prefix("#/components/requestBodies/")?;
47                let components = spec.components.as_ref()?;
48                match components.request_bodies.get(name)? {
49                    ReferenceOr::Item(body) => Some(body),
50                    ReferenceOr::Reference { .. } => None,
51                }
52            }
53        }
54    }
55
56    pub fn resolve_schema<'a>(
57        schema_ref: &'a ReferenceOr<Schema>,
58        spec: &'a OpenAPI,
59    ) -> Option<&'a Schema> {
60        resolve_schema_with_visited(schema_ref, spec, &mut HashSet::new())
61    }
62
63    fn resolve_schema_with_visited<'a>(
64        schema_ref: &'a ReferenceOr<Schema>,
65        spec: &'a OpenAPI,
66        visited: &mut HashSet<String>,
67    ) -> Option<&'a Schema> {
68        match schema_ref {
69            ReferenceOr::Item(schema) => Some(schema),
70            ReferenceOr::Reference { reference } => {
71                if !visited.insert(reference.clone()) {
72                    return None; // Cycle detected
73                }
74                let name = reference.strip_prefix("#/components/schemas/")?;
75                let components = spec.components.as_ref()?;
76                let nested = components.schemas.get(name)?;
77                resolve_schema_with_visited(nested, spec, visited)
78            }
79        }
80    }
81
82    /// Resolve a boxed schema reference (used by array items and object properties)
83    pub fn resolve_boxed_schema<'a>(
84        schema_ref: &'a ReferenceOr<Box<Schema>>,
85        spec: &'a OpenAPI,
86    ) -> Option<&'a Schema> {
87        match schema_ref {
88            ReferenceOr::Item(schema) => Some(schema.as_ref()),
89            ReferenceOr::Reference { reference } => {
90                // Delegate to the regular schema resolver
91                let name = reference.strip_prefix("#/components/schemas/")?;
92                let components = spec.components.as_ref()?;
93                let nested = components.schemas.get(name)?;
94                resolve_schema_with_visited(nested, spec, &mut HashSet::new())
95            }
96        }
97    }
98
99    pub fn resolve_response<'a>(
100        resp_ref: &'a ReferenceOr<Response>,
101        spec: &'a OpenAPI,
102    ) -> Option<&'a Response> {
103        match resp_ref {
104            ReferenceOr::Item(resp) => Some(resp),
105            ReferenceOr::Reference { reference } => {
106                let name = reference.strip_prefix("#/components/responses/")?;
107                let components = spec.components.as_ref()?;
108                match components.responses.get(name)? {
109                    ReferenceOr::Item(resp) => Some(resp),
110                    ReferenceOr::Reference { .. } => None,
111                }
112            }
113        }
114    }
115}
116
117/// An API operation annotated with the conformance features it exercises
118#[derive(Debug, Clone)]
119pub struct AnnotatedOperation {
120    pub path: String,
121    pub method: String,
122    pub features: Vec<ConformanceFeature>,
123    pub request_body_content_type: Option<String>,
124    pub sample_body: Option<String>,
125    pub query_params: Vec<(String, String)>,
126    pub header_params: Vec<(String, String)>,
127    pub path_params: Vec<(String, String)>,
128    /// Response schema for validation (JSON string of the schema)
129    pub response_schema: Option<Schema>,
130}
131
132/// Generates spec-driven conformance k6 scripts
133pub struct SpecDrivenConformanceGenerator {
134    config: ConformanceConfig,
135    operations: Vec<AnnotatedOperation>,
136}
137
138impl SpecDrivenConformanceGenerator {
139    pub fn new(config: ConformanceConfig, operations: Vec<AnnotatedOperation>) -> Self {
140        Self { config, operations }
141    }
142
143    /// Annotate a list of API operations with conformance features
144    pub fn annotate_operations(
145        operations: &[ApiOperation],
146        spec: &OpenAPI,
147    ) -> Vec<AnnotatedOperation> {
148        operations.iter().map(|op| Self::annotate_operation(op, spec)).collect()
149    }
150
151    /// Analyze an operation and determine which conformance features it exercises
152    fn annotate_operation(op: &ApiOperation, spec: &OpenAPI) -> AnnotatedOperation {
153        let mut features = Vec::new();
154        let mut query_params = Vec::new();
155        let mut header_params = Vec::new();
156        let mut path_params = Vec::new();
157
158        // Detect HTTP method feature
159        match op.method.to_uppercase().as_str() {
160            "GET" => features.push(ConformanceFeature::MethodGet),
161            "POST" => features.push(ConformanceFeature::MethodPost),
162            "PUT" => features.push(ConformanceFeature::MethodPut),
163            "PATCH" => features.push(ConformanceFeature::MethodPatch),
164            "DELETE" => features.push(ConformanceFeature::MethodDelete),
165            "HEAD" => features.push(ConformanceFeature::MethodHead),
166            "OPTIONS" => features.push(ConformanceFeature::MethodOptions),
167            _ => {}
168        }
169
170        // Detect parameter features (resolves $ref)
171        for param_ref in &op.operation.parameters {
172            if let Some(param) = ref_resolver::resolve_parameter(param_ref, spec) {
173                Self::annotate_parameter(
174                    param,
175                    spec,
176                    &mut features,
177                    &mut query_params,
178                    &mut header_params,
179                    &mut path_params,
180                );
181            }
182        }
183
184        // Detect path parameters from the path template itself
185        for segment in op.path.split('/') {
186            if segment.starts_with('{') && segment.ends_with('}') {
187                let name = &segment[1..segment.len() - 1];
188                // Only add if not already found from parameters
189                if !path_params.iter().any(|(n, _)| n == name) {
190                    path_params.push((name.to_string(), "test-value".to_string()));
191                    // Determine type from path params we didn't already handle
192                    if !features.contains(&ConformanceFeature::PathParamString)
193                        && !features.contains(&ConformanceFeature::PathParamInteger)
194                    {
195                        features.push(ConformanceFeature::PathParamString);
196                    }
197                }
198            }
199        }
200
201        // Detect request body features (resolves $ref)
202        let mut request_body_content_type = None;
203        let mut sample_body = None;
204
205        let resolved_body = op
206            .operation
207            .request_body
208            .as_ref()
209            .and_then(|b| ref_resolver::resolve_request_body(b, spec));
210
211        if let Some(body) = resolved_body {
212            for (content_type, _media) in &body.content {
213                match content_type.as_str() {
214                    "application/json" => {
215                        features.push(ConformanceFeature::BodyJson);
216                        request_body_content_type = Some("application/json".to_string());
217                        // Generate sample body from schema
218                        if let Ok(template) = RequestGenerator::generate_template(op) {
219                            if let Some(body_val) = &template.body {
220                                sample_body = Some(body_val.to_string());
221                            }
222                        }
223                    }
224                    "application/x-www-form-urlencoded" => {
225                        features.push(ConformanceFeature::BodyFormUrlencoded);
226                        request_body_content_type =
227                            Some("application/x-www-form-urlencoded".to_string());
228                    }
229                    "multipart/form-data" => {
230                        features.push(ConformanceFeature::BodyMultipart);
231                        request_body_content_type = Some("multipart/form-data".to_string());
232                    }
233                    _ => {}
234                }
235            }
236
237            // Detect schema features in request body (resolves $ref in schema)
238            if let Some(media) = body.content.get("application/json") {
239                if let Some(schema_ref) = &media.schema {
240                    if let Some(schema) = ref_resolver::resolve_schema(schema_ref, spec) {
241                        Self::annotate_schema(schema, spec, &mut features);
242                    }
243                }
244            }
245        }
246
247        // Detect response code features
248        Self::annotate_responses(&op.operation, spec, &mut features);
249
250        // Extract response schema for validation (resolves $ref)
251        let response_schema = Self::extract_response_schema(&op.operation, spec);
252        if response_schema.is_some() {
253            features.push(ConformanceFeature::ResponseValidation);
254        }
255
256        // Detect security features
257        Self::annotate_security(&op.operation, spec, &mut features);
258
259        // Deduplicate features
260        features.sort_by_key(|f| f.check_name());
261        features.dedup_by_key(|f| f.check_name());
262
263        AnnotatedOperation {
264            path: op.path.clone(),
265            method: op.method.to_uppercase(),
266            features,
267            request_body_content_type,
268            sample_body,
269            query_params,
270            header_params,
271            path_params,
272            response_schema,
273        }
274    }
275
276    /// Annotate parameter features
277    fn annotate_parameter(
278        param: &Parameter,
279        spec: &OpenAPI,
280        features: &mut Vec<ConformanceFeature>,
281        query_params: &mut Vec<(String, String)>,
282        header_params: &mut Vec<(String, String)>,
283        path_params: &mut Vec<(String, String)>,
284    ) {
285        let (location, data) = match param {
286            Parameter::Query { parameter_data, .. } => ("query", parameter_data),
287            Parameter::Path { parameter_data, .. } => ("path", parameter_data),
288            Parameter::Header { parameter_data, .. } => ("header", parameter_data),
289            Parameter::Cookie { .. } => {
290                features.push(ConformanceFeature::CookieParam);
291                return;
292            }
293        };
294
295        // Detect type from schema
296        let is_integer = Self::param_schema_is_integer(data, spec);
297        let is_array = Self::param_schema_is_array(data, spec);
298
299        // Generate sample value
300        let sample = if is_integer {
301            "42".to_string()
302        } else if is_array {
303            "a,b".to_string()
304        } else {
305            "test-value".to_string()
306        };
307
308        match location {
309            "path" => {
310                if is_integer {
311                    features.push(ConformanceFeature::PathParamInteger);
312                } else {
313                    features.push(ConformanceFeature::PathParamString);
314                }
315                path_params.push((data.name.clone(), sample));
316            }
317            "query" => {
318                if is_array {
319                    features.push(ConformanceFeature::QueryParamArray);
320                } else if is_integer {
321                    features.push(ConformanceFeature::QueryParamInteger);
322                } else {
323                    features.push(ConformanceFeature::QueryParamString);
324                }
325                query_params.push((data.name.clone(), sample));
326            }
327            "header" => {
328                features.push(ConformanceFeature::HeaderParam);
329                header_params.push((data.name.clone(), sample));
330            }
331            _ => {}
332        }
333
334        // Check for constraint features on the parameter (resolves $ref)
335        if let ParameterSchemaOrContent::Schema(schema_ref) = &data.format {
336            if let Some(schema) = ref_resolver::resolve_schema(schema_ref, spec) {
337                Self::annotate_schema(schema, spec, features);
338            }
339        }
340
341        // Required/optional
342        if data.required {
343            features.push(ConformanceFeature::ConstraintRequired);
344        } else {
345            features.push(ConformanceFeature::ConstraintOptional);
346        }
347    }
348
349    fn param_schema_is_integer(data: &openapiv3::ParameterData, spec: &OpenAPI) -> bool {
350        if let ParameterSchemaOrContent::Schema(schema_ref) = &data.format {
351            if let Some(schema) = ref_resolver::resolve_schema(schema_ref, spec) {
352                return matches!(&schema.schema_kind, SchemaKind::Type(Type::Integer(_)));
353            }
354        }
355        false
356    }
357
358    fn param_schema_is_array(data: &openapiv3::ParameterData, spec: &OpenAPI) -> bool {
359        if let ParameterSchemaOrContent::Schema(schema_ref) = &data.format {
360            if let Some(schema) = ref_resolver::resolve_schema(schema_ref, spec) {
361                return matches!(&schema.schema_kind, SchemaKind::Type(Type::Array(_)));
362            }
363        }
364        false
365    }
366
367    /// Annotate schema-level features (types, composition, formats, constraints)
368    fn annotate_schema(schema: &Schema, spec: &OpenAPI, features: &mut Vec<ConformanceFeature>) {
369        match &schema.schema_kind {
370            SchemaKind::Type(Type::String(s)) => {
371                features.push(ConformanceFeature::SchemaString);
372                // Check format
373                match &s.format {
374                    VariantOrUnknownOrEmpty::Item(StringFormat::Date) => {
375                        features.push(ConformanceFeature::FormatDate);
376                    }
377                    VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => {
378                        features.push(ConformanceFeature::FormatDateTime);
379                    }
380                    VariantOrUnknownOrEmpty::Unknown(fmt) => match fmt.as_str() {
381                        "email" => features.push(ConformanceFeature::FormatEmail),
382                        "uuid" => features.push(ConformanceFeature::FormatUuid),
383                        "uri" | "url" => features.push(ConformanceFeature::FormatUri),
384                        "ipv4" => features.push(ConformanceFeature::FormatIpv4),
385                        "ipv6" => features.push(ConformanceFeature::FormatIpv6),
386                        _ => {}
387                    },
388                    _ => {}
389                }
390                // Check constraints
391                if s.pattern.is_some() {
392                    features.push(ConformanceFeature::ConstraintPattern);
393                }
394                if !s.enumeration.is_empty() {
395                    features.push(ConformanceFeature::ConstraintEnum);
396                }
397                if s.min_length.is_some() || s.max_length.is_some() {
398                    features.push(ConformanceFeature::ConstraintMinMax);
399                }
400            }
401            SchemaKind::Type(Type::Integer(i)) => {
402                features.push(ConformanceFeature::SchemaInteger);
403                if i.minimum.is_some() || i.maximum.is_some() {
404                    features.push(ConformanceFeature::ConstraintMinMax);
405                }
406                if !i.enumeration.is_empty() {
407                    features.push(ConformanceFeature::ConstraintEnum);
408                }
409            }
410            SchemaKind::Type(Type::Number(n)) => {
411                features.push(ConformanceFeature::SchemaNumber);
412                if n.minimum.is_some() || n.maximum.is_some() {
413                    features.push(ConformanceFeature::ConstraintMinMax);
414                }
415            }
416            SchemaKind::Type(Type::Boolean(_)) => {
417                features.push(ConformanceFeature::SchemaBoolean);
418            }
419            SchemaKind::Type(Type::Array(arr)) => {
420                features.push(ConformanceFeature::SchemaArray);
421                if let Some(item_ref) = &arr.items {
422                    if let Some(item_schema) = ref_resolver::resolve_boxed_schema(item_ref, spec) {
423                        Self::annotate_schema(item_schema, spec, features);
424                    }
425                }
426            }
427            SchemaKind::Type(Type::Object(obj)) => {
428                features.push(ConformanceFeature::SchemaObject);
429                // Check required fields
430                if !obj.required.is_empty() {
431                    features.push(ConformanceFeature::ConstraintRequired);
432                }
433                // Walk properties (resolves $ref)
434                for (_name, prop_ref) in &obj.properties {
435                    if let Some(prop_schema) = ref_resolver::resolve_boxed_schema(prop_ref, spec) {
436                        Self::annotate_schema(prop_schema, spec, features);
437                    }
438                }
439            }
440            SchemaKind::OneOf { .. } => {
441                features.push(ConformanceFeature::CompositionOneOf);
442            }
443            SchemaKind::AnyOf { .. } => {
444                features.push(ConformanceFeature::CompositionAnyOf);
445            }
446            SchemaKind::AllOf { .. } => {
447                features.push(ConformanceFeature::CompositionAllOf);
448            }
449            _ => {}
450        }
451    }
452
453    /// Detect response code features (resolves $ref in responses)
454    fn annotate_responses(
455        operation: &Operation,
456        spec: &OpenAPI,
457        features: &mut Vec<ConformanceFeature>,
458    ) {
459        for (status_code, resp_ref) in &operation.responses.responses {
460            // Only count features for responses that actually resolve
461            if ref_resolver::resolve_response(resp_ref, spec).is_some() {
462                match status_code {
463                    openapiv3::StatusCode::Code(200) => {
464                        features.push(ConformanceFeature::Response200)
465                    }
466                    openapiv3::StatusCode::Code(201) => {
467                        features.push(ConformanceFeature::Response201)
468                    }
469                    openapiv3::StatusCode::Code(204) => {
470                        features.push(ConformanceFeature::Response204)
471                    }
472                    openapiv3::StatusCode::Code(400) => {
473                        features.push(ConformanceFeature::Response400)
474                    }
475                    openapiv3::StatusCode::Code(404) => {
476                        features.push(ConformanceFeature::Response404)
477                    }
478                    _ => {}
479                }
480            }
481        }
482    }
483
484    /// Extract the response schema for the primary success response (200 or 201)
485    /// Resolves $ref for both the response and the schema within it.
486    fn extract_response_schema(operation: &Operation, spec: &OpenAPI) -> Option<Schema> {
487        // Try 200 first, then 201
488        for code in [200u16, 201] {
489            if let Some(resp_ref) =
490                operation.responses.responses.get(&openapiv3::StatusCode::Code(code))
491            {
492                if let Some(response) = ref_resolver::resolve_response(resp_ref, spec) {
493                    if let Some(media) = response.content.get("application/json") {
494                        if let Some(schema_ref) = &media.schema {
495                            if let Some(schema) = ref_resolver::resolve_schema(schema_ref, spec) {
496                                return Some(schema.clone());
497                            }
498                        }
499                    }
500                }
501            }
502        }
503        None
504    }
505
506    /// Detect security scheme features
507    fn annotate_security(
508        operation: &Operation,
509        _spec: &OpenAPI,
510        features: &mut Vec<ConformanceFeature>,
511    ) {
512        if let Some(security) = &operation.security {
513            for security_req in security {
514                for scheme_name in security_req.keys() {
515                    let name_lower = scheme_name.to_lowercase();
516                    if name_lower.contains("bearer") || name_lower.contains("jwt") {
517                        features.push(ConformanceFeature::SecurityBearer);
518                    } else if name_lower.contains("api") && name_lower.contains("key") {
519                        features.push(ConformanceFeature::SecurityApiKey);
520                    } else if name_lower.contains("basic") {
521                        features.push(ConformanceFeature::SecurityBasic);
522                    }
523                }
524            }
525        }
526    }
527
528    /// Generate the k6 conformance script
529    pub fn generate(&self) -> Result<String> {
530        let mut script = String::with_capacity(16384);
531
532        // Imports
533        script.push_str("import http from 'k6/http';\n");
534        script.push_str("import { check, group } from 'k6';\n\n");
535
536        // Options
537        script.push_str("export const options = {\n");
538        script.push_str("  vus: 1,\n");
539        script.push_str("  iterations: 1,\n");
540        if self.config.skip_tls_verify {
541            script.push_str("  insecureSkipTLSVerify: true,\n");
542        }
543        script.push_str("  thresholds: {\n");
544        script.push_str("    checks: ['rate>0'],\n");
545        script.push_str("  },\n");
546        script.push_str("};\n\n");
547
548        // Base URL
549        script.push_str(&format!("const BASE_URL = '{}';\n\n", self.config.target_url));
550        script.push_str("const JSON_HEADERS = { 'Content-Type': 'application/json' };\n\n");
551
552        // Default function
553        script.push_str("export default function () {\n");
554
555        // Group operations by category
556        let mut category_ops: std::collections::BTreeMap<
557            &'static str,
558            Vec<(&AnnotatedOperation, &ConformanceFeature)>,
559        > = std::collections::BTreeMap::new();
560
561        for op in &self.operations {
562            for feature in &op.features {
563                let category = feature.category();
564                if self.config.should_include_category(category) {
565                    category_ops.entry(category).or_default().push((op, feature));
566                }
567            }
568        }
569
570        // Emit grouped tests
571        for (category, ops) in &category_ops {
572            script.push_str(&format!("  group('{}', function () {{\n", category));
573
574            // Track which check names we've already emitted to avoid duplicates
575            let mut emitted_checks: std::collections::HashSet<&str> =
576                std::collections::HashSet::new();
577
578            for (op, feature) in ops {
579                if !emitted_checks.insert(feature.check_name()) {
580                    continue; // Skip duplicate check names
581                }
582
583                self.emit_check(&mut script, op, feature);
584            }
585
586            script.push_str("  });\n\n");
587        }
588
589        script.push_str("}\n\n");
590
591        // handleSummary
592        self.generate_handle_summary(&mut script);
593
594        Ok(script)
595    }
596
597    /// Emit a single k6 check for an operation + feature
598    fn emit_check(
599        &self,
600        script: &mut String,
601        op: &AnnotatedOperation,
602        feature: &ConformanceFeature,
603    ) {
604        script.push_str("    {\n");
605
606        // Build the URL path with parameters substituted
607        let mut url_path = op.path.clone();
608        for (name, value) in &op.path_params {
609            url_path = url_path.replace(&format!("{{{}}}", name), value);
610        }
611
612        // Build query string
613        if !op.query_params.is_empty() {
614            let qs: Vec<String> =
615                op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
616            url_path = format!("{}?{}", url_path, qs.join("&"));
617        }
618
619        let full_url = format!("${{BASE_URL}}{}", url_path);
620
621        // Determine HTTP method and emit request
622        match op.method.as_str() {
623            "GET" => {
624                if !op.header_params.is_empty() {
625                    let headers_obj = Self::format_headers(&op.header_params);
626                    script.push_str(&format!(
627                        "      let res = http.get(`{}`, {{ headers: {} }});\n",
628                        full_url, headers_obj
629                    ));
630                } else {
631                    script.push_str(&format!("      let res = http.get(`{}`);\n", full_url));
632                }
633            }
634            "POST" => {
635                self.emit_request_with_body(script, "post", &full_url, op);
636            }
637            "PUT" => {
638                self.emit_request_with_body(script, "put", &full_url, op);
639            }
640            "PATCH" => {
641                self.emit_request_with_body(script, "patch", &full_url, op);
642            }
643            "DELETE" => {
644                script.push_str(&format!("      let res = http.del(`{}`);\n", full_url));
645            }
646            "HEAD" => {
647                script.push_str(&format!("      let res = http.head(`{}`);\n", full_url));
648            }
649            "OPTIONS" => {
650                script.push_str(&format!("      let res = http.options(`{}`);\n", full_url));
651            }
652            _ => {
653                script.push_str(&format!("      let res = http.get(`{}`);\n", full_url));
654            }
655        }
656
657        // Check: emit assertion based on feature type
658        let check_name = feature.check_name();
659        if matches!(
660            feature,
661            ConformanceFeature::Response200
662                | ConformanceFeature::Response201
663                | ConformanceFeature::Response204
664                | ConformanceFeature::Response400
665                | ConformanceFeature::Response404
666        ) {
667            let expected_code = match feature {
668                ConformanceFeature::Response200 => 200,
669                ConformanceFeature::Response201 => 201,
670                ConformanceFeature::Response204 => 204,
671                ConformanceFeature::Response400 => 400,
672                ConformanceFeature::Response404 => 404,
673                _ => 200,
674            };
675            script.push_str(&format!(
676                "      check(res, {{ '{}': (r) => r.status === {} }});\n",
677                check_name, expected_code
678            ));
679        } else if matches!(feature, ConformanceFeature::ResponseValidation) {
680            // Response schema validation — validate the response body against the schema
681            if let Some(schema) = &op.response_schema {
682                let validation_js = SchemaValidatorGenerator::generate_validation(schema);
683                script.push_str(&format!(
684                    "      try {{ let body = res.json(); check(res, {{ '{}': (r) => {{ {} }} }}); }} catch(e) {{ check(res, {{ '{}': () => false }}); }}\n",
685                    check_name, validation_js, check_name
686                ));
687            }
688        } else {
689            script.push_str(&format!(
690                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
691                check_name
692            ));
693        }
694
695        script.push_str("    }\n");
696    }
697
698    /// Emit an HTTP request with a body
699    fn emit_request_with_body(
700        &self,
701        script: &mut String,
702        method: &str,
703        url: &str,
704        op: &AnnotatedOperation,
705    ) {
706        if let Some(body) = &op.sample_body {
707            let escaped_body = body.replace('\'', "\\'");
708            let mut headers = "JSON_HEADERS".to_string();
709            if !op.header_params.is_empty() {
710                headers = format!(
711                    "Object.assign({{}}, JSON_HEADERS, {})",
712                    Self::format_headers(&op.header_params)
713                );
714            }
715            script.push_str(&format!(
716                "      let res = http.{}(`{}`, '{}', {{ headers: {} }});\n",
717                method, url, escaped_body, headers
718            ));
719        } else {
720            script.push_str(&format!("      let res = http.{}(`{}`, null);\n", method, url));
721        }
722    }
723
724    /// Format header params as a JS object literal
725    fn format_headers(headers: &[(String, String)]) -> String {
726        let entries: Vec<String> =
727            headers.iter().map(|(k, v)| format!("'{}': '{}'", k, v)).collect();
728        format!("{{ {} }}", entries.join(", "))
729    }
730
731    /// handleSummary — same format as reference mode for report compatibility
732    fn generate_handle_summary(&self, script: &mut String) {
733        script.push_str("export function handleSummary(data) {\n");
734        script.push_str("  let checks = {};\n");
735        script.push_str("  if (data.metrics && data.metrics.checks) {\n");
736        script.push_str("    checks.overall_pass_rate = data.metrics.checks.values.rate;\n");
737        script.push_str("  }\n");
738        script.push_str("  let checkResults = {};\n");
739        script.push_str("  function walkGroups(group) {\n");
740        script.push_str("    if (group.checks) {\n");
741        script.push_str("      for (let checkObj of group.checks) {\n");
742        script.push_str("        checkResults[checkObj.name] = {\n");
743        script.push_str("          passes: checkObj.passes,\n");
744        script.push_str("          fails: checkObj.fails,\n");
745        script.push_str("        };\n");
746        script.push_str("      }\n");
747        script.push_str("    }\n");
748        script.push_str("    if (group.groups) {\n");
749        script.push_str("      for (let subGroup of group.groups) {\n");
750        script.push_str("        walkGroups(subGroup);\n");
751        script.push_str("      }\n");
752        script.push_str("    }\n");
753        script.push_str("  }\n");
754        script.push_str("  if (data.root_group) {\n");
755        script.push_str("    walkGroups(data.root_group);\n");
756        script.push_str("  }\n");
757        script.push_str("  return {\n");
758        script.push_str("    'conformance-report.json': JSON.stringify({ checks: checkResults, overall: checks }, null, 2),\n");
759        script.push_str("    stdout: textSummary(data, { indent: '  ', enableColors: true }),\n");
760        script.push_str("  };\n");
761        script.push_str("}\n\n");
762        script.push_str("function textSummary(data, opts) {\n");
763        script.push_str("  return JSON.stringify(data, null, 2);\n");
764        script.push_str("}\n");
765    }
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771    use openapiv3::{
772        Operation, ParameterData, ParameterSchemaOrContent, PathStyle, Response, Schema,
773        SchemaData, SchemaKind, StringType, Type,
774    };
775
776    fn make_op(method: &str, path: &str, operation: Operation) -> ApiOperation {
777        ApiOperation {
778            method: method.to_string(),
779            path: path.to_string(),
780            operation,
781            operation_id: None,
782        }
783    }
784
785    fn empty_spec() -> OpenAPI {
786        OpenAPI::default()
787    }
788
789    #[test]
790    fn test_annotate_get_with_path_param() {
791        let mut op = Operation::default();
792        op.parameters.push(ReferenceOr::Item(Parameter::Path {
793            parameter_data: ParameterData {
794                name: "id".to_string(),
795                description: None,
796                required: true,
797                deprecated: None,
798                format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(Schema {
799                    schema_data: SchemaData::default(),
800                    schema_kind: SchemaKind::Type(Type::String(StringType::default())),
801                })),
802                example: None,
803                examples: Default::default(),
804                explode: None,
805                extensions: Default::default(),
806            },
807            style: PathStyle::Simple,
808        }));
809
810        let api_op = make_op("get", "/users/{id}", op);
811        let annotated = SpecDrivenConformanceGenerator::annotate_operation(&api_op, &empty_spec());
812
813        assert!(annotated.features.contains(&ConformanceFeature::MethodGet));
814        assert!(annotated.features.contains(&ConformanceFeature::PathParamString));
815        assert!(annotated.features.contains(&ConformanceFeature::ConstraintRequired));
816        assert_eq!(annotated.path_params.len(), 1);
817        assert_eq!(annotated.path_params[0].0, "id");
818    }
819
820    #[test]
821    fn test_annotate_post_with_json_body() {
822        let mut op = Operation::default();
823        let mut body = openapiv3::RequestBody::default();
824        body.required = true;
825        body.content
826            .insert("application/json".to_string(), openapiv3::MediaType::default());
827        op.request_body = Some(ReferenceOr::Item(body));
828
829        let api_op = make_op("post", "/items", op);
830        let annotated = SpecDrivenConformanceGenerator::annotate_operation(&api_op, &empty_spec());
831
832        assert!(annotated.features.contains(&ConformanceFeature::MethodPost));
833        assert!(annotated.features.contains(&ConformanceFeature::BodyJson));
834    }
835
836    #[test]
837    fn test_annotate_response_codes() {
838        let mut op = Operation::default();
839        op.responses
840            .responses
841            .insert(openapiv3::StatusCode::Code(200), ReferenceOr::Item(Response::default()));
842        op.responses
843            .responses
844            .insert(openapiv3::StatusCode::Code(404), ReferenceOr::Item(Response::default()));
845
846        let api_op = make_op("get", "/items", op);
847        let annotated = SpecDrivenConformanceGenerator::annotate_operation(&api_op, &empty_spec());
848
849        assert!(annotated.features.contains(&ConformanceFeature::Response200));
850        assert!(annotated.features.contains(&ConformanceFeature::Response404));
851    }
852
853    #[test]
854    fn test_generate_spec_driven_script() {
855        let config = ConformanceConfig {
856            target_url: "http://localhost:3000".to_string(),
857            api_key: None,
858            basic_auth: None,
859            skip_tls_verify: false,
860            categories: None,
861        };
862
863        let operations = vec![AnnotatedOperation {
864            path: "/users/{id}".to_string(),
865            method: "GET".to_string(),
866            features: vec![
867                ConformanceFeature::MethodGet,
868                ConformanceFeature::PathParamString,
869            ],
870            request_body_content_type: None,
871            sample_body: None,
872            query_params: vec![],
873            header_params: vec![],
874            path_params: vec![("id".to_string(), "test-value".to_string())],
875            response_schema: None,
876        }];
877
878        let gen = SpecDrivenConformanceGenerator::new(config, operations);
879        let script = gen.generate().unwrap();
880
881        assert!(script.contains("import http from 'k6/http'"));
882        assert!(script.contains("/users/test-value"));
883        assert!(script.contains("param:path:string"));
884        assert!(script.contains("method:GET"));
885        assert!(script.contains("handleSummary"));
886    }
887
888    #[test]
889    fn test_generate_with_category_filter() {
890        let config = ConformanceConfig {
891            target_url: "http://localhost:3000".to_string(),
892            api_key: None,
893            basic_auth: None,
894            skip_tls_verify: false,
895            categories: Some(vec!["Parameters".to_string()]),
896        };
897
898        let operations = vec![AnnotatedOperation {
899            path: "/users/{id}".to_string(),
900            method: "GET".to_string(),
901            features: vec![
902                ConformanceFeature::MethodGet,
903                ConformanceFeature::PathParamString,
904            ],
905            request_body_content_type: None,
906            sample_body: None,
907            query_params: vec![],
908            header_params: vec![],
909            path_params: vec![("id".to_string(), "1".to_string())],
910            response_schema: None,
911        }];
912
913        let gen = SpecDrivenConformanceGenerator::new(config, operations);
914        let script = gen.generate().unwrap();
915
916        assert!(script.contains("group('Parameters'"));
917        assert!(!script.contains("group('HTTP Methods'"));
918    }
919}