1use crate::error::{BenchError, Result};
4use std::path::{Path, PathBuf};
5
6use super::custom::CustomConformanceConfig;
7
8#[derive(Default, Clone)]
10pub struct ConformanceConfig {
11 pub target_url: String,
13 pub api_key: Option<String>,
15 pub basic_auth: Option<String>,
17 pub skip_tls_verify: bool,
19 pub categories: Option<Vec<String>>,
21 pub base_path: Option<String>,
23 pub custom_headers: Vec<(String, String)>,
27 pub output_dir: Option<PathBuf>,
31 pub all_operations: bool,
34 pub custom_checks_file: Option<PathBuf>,
36 pub request_delay_ms: u64,
39 pub custom_filter: Option<String>,
42 pub export_requests: bool,
45 pub validate_requests: bool,
48}
49
50impl ConformanceConfig {
51 pub fn should_include_category(&self, category: &str) -> bool {
53 match &self.categories {
54 None => true,
55 Some(cats) => cats.iter().any(|c| c.eq_ignore_ascii_case(category)),
56 }
57 }
58
59 pub fn has_custom_headers(&self) -> bool {
61 !self.custom_headers.is_empty()
62 }
63
64 pub fn has_cookie_header(&self) -> bool {
68 self.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("cookie"))
69 }
70
71 pub fn custom_headers_js_object(&self) -> String {
73 let entries: Vec<String> = self
74 .custom_headers
75 .iter()
76 .map(|(k, v)| format!("'{}': '{}'", k, v.replace('\'', "\\'")))
77 .collect();
78 format!("{{ {} }}", entries.join(", "))
79 }
80
81 pub fn generate_custom_group(&self) -> Result<Option<String>> {
85 let path = match &self.custom_checks_file {
86 Some(p) => p,
87 None => return Ok(None),
88 };
89 let mut config = CustomConformanceConfig::from_file(path)?;
90 if config.custom_checks.is_empty() {
91 return Ok(None);
92 }
93
94 if let Some(ref pattern) = self.custom_filter {
96 let re = regex::Regex::new(pattern).map_err(|e| {
97 BenchError::Other(format!("Invalid --conformance-custom-filter regex: {}", e))
98 })?;
99 let total = config.custom_checks.len();
100 config.custom_checks.retain(|c| re.is_match(&c.name) || re.is_match(&c.path));
101 tracing::info!(
102 "Custom check filter: {}/{} checks matched pattern",
103 config.custom_checks.len(),
104 total
105 );
106 if config.custom_checks.is_empty() {
107 return Ok(None);
108 }
109 }
110
111 Ok(Some(config.generate_k6_group_with_options(
112 "BASE_URL",
113 &self.custom_headers,
114 self.export_requests,
115 )))
116 }
117
118 pub fn effective_base_url(&self) -> String {
123 let base = match &self.base_path {
124 None => self.target_url.trim_end_matches('/').to_string(),
125 Some(bp) if bp.is_empty() => self.target_url.trim_end_matches('/').to_string(),
126 Some(bp) => {
127 let url = self.target_url.trim_end_matches('/');
128 let path = if bp.starts_with('/') {
129 bp.as_str()
130 } else {
131 return format!("{}/{}", url, bp).trim_end_matches('/').to_string();
132 };
133 format!("{}{}", url, path).trim_end_matches('/').to_string()
134 }
135 };
136 base
137 }
138}
139
140pub struct ConformanceGenerator {
142 config: ConformanceConfig,
143}
144
145impl ConformanceGenerator {
146 pub fn new(config: ConformanceConfig) -> Self {
147 Self { config }
148 }
149
150 pub fn generate(&self) -> Result<String> {
152 let mut script = String::with_capacity(16384);
153
154 script.push_str("import http from 'k6/http';\n");
156 script.push_str("import { check, group } from 'k6';\n");
157 if self.config.request_delay_ms > 0 {
158 script.push_str("import { sleep } from 'k6';\n");
159 }
160 script.push('\n');
161
162 script.push_str(
166 "http.setResponseCallback(http.expectedStatuses({ min: 100, max: 599 }));\n\n",
167 );
168
169 script.push_str("export const options = {\n");
171 script.push_str(" vus: 1,\n");
172 script.push_str(" iterations: 1,\n");
173 if self.config.skip_tls_verify {
174 script.push_str(" insecureSkipTLSVerify: true,\n");
175 }
176 script.push_str(" thresholds: {\n");
177 script.push_str(" checks: ['rate>0'],\n");
178 script.push_str(" },\n");
179 script.push_str("};\n\n");
180
181 script.push_str(&format!("const BASE_URL = '{}';\n\n", self.config.effective_base_url()));
183
184 if self.config.request_delay_ms > 0 {
186 script.push_str(&format!(
187 "const REQUEST_DELAY = {:.3};\n\n",
188 self.config.request_delay_ms as f64 / 1000.0
189 ));
190 }
191
192 script.push_str("const JSON_HEADERS = { 'Content-Type': 'application/json' };\n\n");
194
195 script.push_str("function __captureFailure(checkName, res, expected) {\n");
197 script.push_str(" let bodyStr = '';\n");
198 script.push_str(" try { bodyStr = res.body ? res.body.substring(0, 2000) : ''; } catch(e) { bodyStr = '<unreadable>'; }\n");
199 script.push_str(" let reqHeaders = {};\n");
200 script.push_str(
201 " if (res.request && res.request.headers) { reqHeaders = res.request.headers; }\n",
202 );
203 script.push_str(" let reqBody = '';\n");
204 script.push_str(" if (res.request && res.request.body) { try { reqBody = res.request.body.substring(0, 2000); } catch(e) {} }\n");
205 script.push_str(" console.log('MOCKFORGE_FAILURE:' + JSON.stringify({\n");
206 script.push_str(" check: checkName,\n");
207 script.push_str(" request: {\n");
208 script.push_str(" method: res.request ? res.request.method : 'unknown',\n");
209 script.push_str(" url: res.request ? res.request.url : res.url || 'unknown',\n");
210 script.push_str(" headers: reqHeaders,\n");
211 script.push_str(" body: reqBody,\n");
212 script.push_str(" },\n");
213 script.push_str(" response: {\n");
214 script.push_str(" status: res.status,\n");
215 script.push_str(" headers: res.headers ? Object.fromEntries(Object.entries(res.headers).slice(0, 20)) : {},\n");
216 script.push_str(" body: bodyStr,\n");
217 script.push_str(" },\n");
218 script.push_str(" expected: expected,\n");
219 script.push_str(" }));\n");
220 script.push_str("}\n\n");
221
222 if self.config.export_requests {
226 script.push_str("function __captureExchange(checkName, res) {\n");
227 script.push_str(" let bodyStr = '';\n");
228 script.push_str(" try { bodyStr = res.body ? res.body.substring(0, 2000) : ''; } catch(e) { bodyStr = '<unreadable>'; }\n");
229 script.push_str(" let reqHeaders = {};\n");
230 script.push_str(
231 " if (res.request && res.request.headers) { reqHeaders = res.request.headers; }\n",
232 );
233 script.push_str(" let reqBody = '';\n");
234 script.push_str(" if (res.request && res.request.body) { try { reqBody = res.request.body.substring(0, 2000); } catch(e) {} }\n");
235 script.push_str(" console.log('MOCKFORGE_EXCHANGE:' + JSON.stringify({\n");
236 script.push_str(" check: checkName,\n");
237 script.push_str(" request: {\n");
238 script.push_str(" method: res.request ? res.request.method : 'unknown',\n");
239 script.push_str(" url: res.request ? res.request.url : res.url || 'unknown',\n");
240 script.push_str(" headers: reqHeaders,\n");
241 script.push_str(" body: reqBody,\n");
242 script.push_str(" },\n");
243 script.push_str(" response: {\n");
244 script.push_str(" status: res.status,\n");
245 script.push_str(" headers: res.headers ? Object.fromEntries(Object.entries(res.headers).slice(0, 30)) : {},\n");
246 script.push_str(" body: bodyStr,\n");
247 script.push_str(" },\n");
248 script.push_str(" }));\n");
249 script.push_str("}\n\n");
250 }
251
252 script.push_str("export default function () {\n");
254
255 if self.config.has_cookie_header() {
256 script.push_str(
257 " // Clear cookie jar to prevent server Set-Cookie from duplicating custom Cookie header\n",
258 );
259 script.push_str(" http.cookieJar().clear(BASE_URL);\n\n");
260 }
261
262 let delay_between = if self.config.request_delay_ms > 0 {
264 " sleep(REQUEST_DELAY);\n".to_string()
265 } else {
266 String::new()
267 };
268
269 if self.config.should_include_category("Parameters") {
270 self.generate_parameters_group(&mut script);
271 script.push_str(&delay_between);
272 }
273 if self.config.should_include_category("Request Bodies") {
274 self.generate_request_bodies_group(&mut script);
275 script.push_str(&delay_between);
276 }
277 if self.config.should_include_category("Schema Types") {
278 self.generate_schema_types_group(&mut script);
279 script.push_str(&delay_between);
280 }
281 if self.config.should_include_category("Composition") {
282 self.generate_composition_group(&mut script);
283 script.push_str(&delay_between);
284 }
285 if self.config.should_include_category("String Formats") {
286 self.generate_string_formats_group(&mut script);
287 script.push_str(&delay_between);
288 }
289 if self.config.should_include_category("Constraints") {
290 self.generate_constraints_group(&mut script);
291 script.push_str(&delay_between);
292 }
293 if self.config.should_include_category("Response Codes") {
294 self.generate_response_codes_group(&mut script);
295 script.push_str(&delay_between);
296 }
297 if self.config.should_include_category("HTTP Methods") {
298 self.generate_http_methods_group(&mut script);
299 script.push_str(&delay_between);
300 }
301 if self.config.should_include_category("Content Types") {
302 self.generate_content_negotiation_group(&mut script);
303 script.push_str(&delay_between);
304 }
305 if self.config.should_include_category("Security") {
306 self.generate_security_group(&mut script);
307 }
308
309 if let Some(custom_group) = self.config.generate_custom_group()? {
311 script.push_str(&custom_group);
312 }
313
314 script.push_str("}\n\n");
315
316 self.generate_handle_summary(&mut script);
318
319 Ok(script)
320 }
321
322 pub fn write_script(&self, path: &Path) -> Result<()> {
324 let script = self.generate()?;
325 if let Some(parent) = path.parent() {
326 std::fs::create_dir_all(parent)?;
327 }
328 std::fs::write(path, script)
329 .map_err(|e| BenchError::Other(format!("Failed to write conformance script: {}", e)))
330 }
331
332 fn merge_with_custom_headers(&self, headers_expr: &str) -> String {
336 if self.config.has_custom_headers() {
337 format!(
338 "Object.assign({{}}, {}, {})",
339 headers_expr,
340 self.config.custom_headers_js_object()
341 )
342 } else {
343 headers_expr.to_string()
344 }
345 }
346
347 fn emit_get(&self, script: &mut String, url: &str, extra_headers: Option<&str>) {
349 let has_custom = self.config.has_custom_headers();
350 let custom_obj = self.config.custom_headers_js_object();
351 match (extra_headers, has_custom) {
352 (None, false) => {
353 script.push_str(&format!(" let res = http.get(`{}`);\n", url));
354 }
355 (None, true) => {
356 script.push_str(&format!(
357 " let res = http.get(`{}`, {{ headers: {} }});\n",
358 url, custom_obj
359 ));
360 }
361 (Some(hdrs), false) => {
362 script.push_str(&format!(
363 " let res = http.get(`{}`, {{ headers: {} }});\n",
364 url, hdrs
365 ));
366 }
367 (Some(hdrs), true) => {
368 script.push_str(&format!(
369 " let res = http.get(`{}`, {{ headers: Object.assign({{}}, {}, {}) }});\n",
370 url, hdrs, custom_obj
371 ));
372 }
373 }
374 self.maybe_clear_cookie_jar(script);
375 self.maybe_capture_exchange(script);
376 }
377
378 fn emit_post_like(
380 &self,
381 script: &mut String,
382 method: &str,
383 url: &str,
384 body: &str,
385 headers_expr: &str,
386 ) {
387 let merged = self.merge_with_custom_headers(headers_expr);
388 script.push_str(&format!(
389 " let res = http.{}(`{}`, {}, {{ headers: {} }});\n",
390 method, url, body, merged
391 ));
392 self.maybe_clear_cookie_jar(script);
393 self.maybe_capture_exchange(script);
394 }
395
396 fn emit_no_body(&self, script: &mut String, method: &str, url: &str) {
398 if self.config.has_custom_headers() {
399 script.push_str(&format!(
400 " let res = http.{}(`{}`, {{ headers: {} }});\n",
401 method,
402 url,
403 self.config.custom_headers_js_object()
404 ));
405 } else {
406 script.push_str(&format!(" let res = http.{}(`{}`);\n", method, url));
407 }
408 self.maybe_clear_cookie_jar(script);
409 self.maybe_capture_exchange(script);
410 }
411
412 fn maybe_capture_exchange(&self, script: &mut String) {
414 if self.config.export_requests {
415 script.push_str(
416 " if (typeof __captureExchange === 'function') __captureExchange('', res);\n",
417 );
418 }
419 }
420
421 fn maybe_clear_cookie_jar(&self, script: &mut String) {
425 if self.config.has_cookie_header() {
426 script.push_str(" http.cookieJar().clear(BASE_URL);\n");
427 }
428 }
429
430 fn generate_parameters_group(&self, script: &mut String) {
431 script.push_str(" group('Parameters', function () {\n");
432
433 script.push_str(" {\n");
435 self.emit_get(script, "${BASE_URL}/conformance/params/hello", None);
436 script.push_str(
437 " check(res, { 'param:path:string': (r) => r.status >= 200 && r.status < 500 });\n",
438 );
439 script.push_str(" }\n");
440
441 script.push_str(" {\n");
443 self.emit_get(script, "${BASE_URL}/conformance/params/42", None);
444 script.push_str(
445 " check(res, { 'param:path:integer': (r) => r.status >= 200 && r.status < 500 });\n",
446 );
447 script.push_str(" }\n");
448
449 script.push_str(" {\n");
451 self.emit_get(script, "${BASE_URL}/conformance/params/query?name=test", None);
452 script.push_str(
453 " check(res, { 'param:query:string': (r) => r.status >= 200 && r.status < 500 });\n",
454 );
455 script.push_str(" }\n");
456
457 script.push_str(" {\n");
459 self.emit_get(script, "${BASE_URL}/conformance/params/query?count=10", None);
460 script.push_str(
461 " check(res, { 'param:query:integer': (r) => r.status >= 200 && r.status < 500 });\n",
462 );
463 script.push_str(" }\n");
464
465 script.push_str(" {\n");
467 self.emit_get(script, "${BASE_URL}/conformance/params/query?tags=a&tags=b", None);
468 script.push_str(
469 " check(res, { 'param:query:array': (r) => r.status >= 200 && r.status < 500 });\n",
470 );
471 script.push_str(" }\n");
472
473 script.push_str(" {\n");
475 self.emit_get(
476 script,
477 "${BASE_URL}/conformance/params/header",
478 Some("{ 'X-Custom-Param': 'test-value' }"),
479 );
480 script.push_str(
481 " check(res, { 'param:header': (r) => r.status >= 200 && r.status < 500 });\n",
482 );
483 script.push_str(" }\n");
484
485 script.push_str(" {\n");
487 script.push_str(" let jar = http.cookieJar();\n");
488 script.push_str(" jar.set(BASE_URL, 'session', 'abc123');\n");
489 self.emit_get(script, "${BASE_URL}/conformance/params/cookie", None);
490 script.push_str(
491 " check(res, { 'param:cookie': (r) => r.status >= 200 && r.status < 500 });\n",
492 );
493 script.push_str(" }\n");
494
495 script.push_str(" });\n\n");
496 }
497
498 fn generate_request_bodies_group(&self, script: &mut String) {
499 script.push_str(" group('Request Bodies', function () {\n");
500
501 script.push_str(" {\n");
503 self.emit_post_like(
504 script,
505 "post",
506 "${BASE_URL}/conformance/body/json",
507 "JSON.stringify({ name: 'test', value: 42 })",
508 "JSON_HEADERS",
509 );
510 script.push_str(
511 " check(res, { 'body:json': (r) => r.status >= 200 && r.status < 500 });\n",
512 );
513 script.push_str(" }\n");
514
515 script.push_str(" {\n");
517 if self.config.has_custom_headers() {
518 script.push_str(&format!(
519 " let res = http.post(`${{BASE_URL}}/conformance/body/form`, {{ field1: 'value1', field2: 'value2' }}, {{ headers: {} }});\n",
520 self.config.custom_headers_js_object()
521 ));
522 } else {
523 script.push_str(
524 " let res = http.post(`${BASE_URL}/conformance/body/form`, { field1: 'value1', field2: 'value2' });\n",
525 );
526 }
527 self.maybe_clear_cookie_jar(script);
528 script.push_str(
529 " check(res, { 'body:form-urlencoded': (r) => r.status >= 200 && r.status < 500 });\n",
530 );
531 script.push_str(" }\n");
532
533 script.push_str(" {\n");
535 script.push_str(
536 " let data = { field: http.file('test content', 'test.txt', 'text/plain') };\n",
537 );
538 if self.config.has_custom_headers() {
539 script.push_str(&format!(
540 " let res = http.post(`${{BASE_URL}}/conformance/body/multipart`, data, {{ headers: {} }});\n",
541 self.config.custom_headers_js_object()
542 ));
543 } else {
544 script.push_str(
545 " let res = http.post(`${BASE_URL}/conformance/body/multipart`, data);\n",
546 );
547 }
548 self.maybe_clear_cookie_jar(script);
549 script.push_str(
550 " check(res, { 'body:multipart': (r) => r.status >= 200 && r.status < 500 });\n",
551 );
552 script.push_str(" }\n");
553
554 script.push_str(" });\n\n");
555 }
556
557 fn generate_schema_types_group(&self, script: &mut String) {
558 script.push_str(" group('Schema Types', function () {\n");
559
560 let types = [
561 ("string", r#"{ "value": "hello" }"#, "schema:string"),
562 ("integer", r#"{ "value": 42 }"#, "schema:integer"),
563 ("number", r#"{ "value": 3.14 }"#, "schema:number"),
564 ("boolean", r#"{ "value": true }"#, "schema:boolean"),
565 ("array", r#"{ "value": [1, 2, 3] }"#, "schema:array"),
566 ("object", r#"{ "value": { "nested": "data" } }"#, "schema:object"),
567 ];
568
569 for (type_name, body, check_name) in types {
570 script.push_str(" {\n");
571 let url = format!("${{BASE_URL}}/conformance/schema/{}", type_name);
572 let body_str = format!("'{}'", body);
573 self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
574 script.push_str(&format!(
575 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
576 check_name
577 ));
578 script.push_str(" }\n");
579 }
580
581 script.push_str(" });\n\n");
582 }
583
584 fn generate_composition_group(&self, script: &mut String) {
585 script.push_str(" group('Composition', function () {\n");
586
587 let compositions = [
588 ("oneOf", r#"{ "type": "string", "value": "test" }"#, "composition:oneOf"),
589 ("anyOf", r#"{ "value": "test" }"#, "composition:anyOf"),
590 ("allOf", r#"{ "name": "test", "id": 1 }"#, "composition:allOf"),
591 ];
592
593 for (kind, body, check_name) in compositions {
594 script.push_str(" {\n");
595 let url = format!("${{BASE_URL}}/conformance/composition/{}", kind);
596 let body_str = format!("'{}'", body);
597 self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
598 script.push_str(&format!(
599 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
600 check_name
601 ));
602 script.push_str(" }\n");
603 }
604
605 script.push_str(" });\n\n");
606 }
607
608 fn generate_string_formats_group(&self, script: &mut String) {
609 script.push_str(" group('String Formats', function () {\n");
610
611 let formats = [
612 ("date", r#"{ "value": "2024-01-15" }"#, "format:date"),
613 ("date-time", r#"{ "value": "2024-01-15T10:30:00Z" }"#, "format:date-time"),
614 ("email", r#"{ "value": "test@example.com" }"#, "format:email"),
615 ("uuid", r#"{ "value": "550e8400-e29b-41d4-a716-446655440000" }"#, "format:uuid"),
616 ("uri", r#"{ "value": "https://example.com/path" }"#, "format:uri"),
617 ("ipv4", r#"{ "value": "192.168.1.1" }"#, "format:ipv4"),
618 ("ipv6", r#"{ "value": "::1" }"#, "format:ipv6"),
619 ];
620
621 for (fmt, body, check_name) in formats {
622 script.push_str(" {\n");
623 let url = format!("${{BASE_URL}}/conformance/formats/{}", fmt);
624 let body_str = format!("'{}'", body);
625 self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
626 script.push_str(&format!(
627 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
628 check_name
629 ));
630 script.push_str(" }\n");
631 }
632
633 script.push_str(" });\n\n");
634 }
635
636 fn generate_constraints_group(&self, script: &mut String) {
637 script.push_str(" group('Constraints', function () {\n");
638
639 let constraints = [
640 (
641 "required",
642 "JSON.stringify({ required_field: 'present' })",
643 "constraint:required",
644 ),
645 ("optional", "JSON.stringify({})", "constraint:optional"),
646 ("minmax", "JSON.stringify({ value: 50 })", "constraint:minmax"),
647 ("pattern", "JSON.stringify({ value: 'ABC-123' })", "constraint:pattern"),
648 ("enum", "JSON.stringify({ status: 'active' })", "constraint:enum"),
649 ];
650
651 for (kind, body, check_name) in constraints {
652 script.push_str(" {\n");
653 let url = format!("${{BASE_URL}}/conformance/constraints/{}", kind);
654 self.emit_post_like(script, "post", &url, body, "JSON_HEADERS");
655 script.push_str(&format!(
656 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
657 check_name
658 ));
659 script.push_str(" }\n");
660 }
661
662 script.push_str(" });\n\n");
663 }
664
665 fn generate_response_codes_group(&self, script: &mut String) {
666 script.push_str(" group('Response Codes', function () {\n");
667
668 let codes = [
669 ("200", "response:200"),
670 ("201", "response:201"),
671 ("204", "response:204"),
672 ("400", "response:400"),
673 ("404", "response:404"),
674 ];
675
676 for (code, check_name) in codes {
677 script.push_str(" {\n");
678 let url = format!("${{BASE_URL}}/conformance/responses/{}", code);
679 self.emit_get(script, &url, None);
680 script.push_str(&format!(
681 " check(res, {{ '{}': (r) => r.status === {} }});\n",
682 check_name, code
683 ));
684 script.push_str(" }\n");
685 }
686
687 script.push_str(" });\n\n");
688 }
689
690 fn generate_http_methods_group(&self, script: &mut String) {
691 script.push_str(" group('HTTP Methods', function () {\n");
692
693 script.push_str(" {\n");
695 self.emit_get(script, "${BASE_URL}/conformance/methods", None);
696 script.push_str(
697 " check(res, { 'method:GET': (r) => r.status >= 200 && r.status < 500 });\n",
698 );
699 script.push_str(" }\n");
700
701 script.push_str(" {\n");
703 self.emit_post_like(
704 script,
705 "post",
706 "${BASE_URL}/conformance/methods",
707 "JSON.stringify({ action: 'create' })",
708 "JSON_HEADERS",
709 );
710 script.push_str(
711 " check(res, { 'method:POST': (r) => r.status >= 200 && r.status < 500 });\n",
712 );
713 script.push_str(" }\n");
714
715 script.push_str(" {\n");
717 self.emit_post_like(
718 script,
719 "put",
720 "${BASE_URL}/conformance/methods",
721 "JSON.stringify({ action: 'update' })",
722 "JSON_HEADERS",
723 );
724 script.push_str(
725 " check(res, { 'method:PUT': (r) => r.status >= 200 && r.status < 500 });\n",
726 );
727 script.push_str(" }\n");
728
729 script.push_str(" {\n");
731 self.emit_post_like(
732 script,
733 "patch",
734 "${BASE_URL}/conformance/methods",
735 "JSON.stringify({ action: 'patch' })",
736 "JSON_HEADERS",
737 );
738 script.push_str(
739 " check(res, { 'method:PATCH': (r) => r.status >= 200 && r.status < 500 });\n",
740 );
741 script.push_str(" }\n");
742
743 script.push_str(" {\n");
745 self.emit_no_body(script, "del", "${BASE_URL}/conformance/methods");
746 script.push_str(
747 " check(res, { 'method:DELETE': (r) => r.status >= 200 && r.status < 500 });\n",
748 );
749 script.push_str(" }\n");
750
751 script.push_str(" {\n");
753 self.emit_no_body(script, "head", "${BASE_URL}/conformance/methods");
754 script.push_str(
755 " check(res, { 'method:HEAD': (r) => r.status >= 200 && r.status < 500 });\n",
756 );
757 script.push_str(" }\n");
758
759 script.push_str(" {\n");
761 self.emit_no_body(script, "options", "${BASE_URL}/conformance/methods");
762 script.push_str(
763 " check(res, { 'method:OPTIONS': (r) => r.status >= 200 && r.status < 500 });\n",
764 );
765 script.push_str(" }\n");
766
767 script.push_str(" });\n\n");
768 }
769
770 fn generate_content_negotiation_group(&self, script: &mut String) {
771 script.push_str(" group('Content Types', function () {\n");
772
773 script.push_str(" {\n");
774 self.emit_get(
775 script,
776 "${BASE_URL}/conformance/content-types",
777 Some("{ 'Accept': 'application/json' }"),
778 );
779 script.push_str(
780 " check(res, { 'content:negotiation': (r) => r.status >= 200 && r.status < 500 });\n",
781 );
782 script.push_str(" }\n");
783
784 script.push_str(" });\n\n");
785 }
786
787 fn generate_security_group(&self, script: &mut String) {
788 script.push_str(" group('Security', function () {\n");
789
790 script.push_str(" {\n");
792 self.emit_get(
793 script,
794 "${BASE_URL}/conformance/security/bearer",
795 Some("{ 'Authorization': 'Bearer test-token-123' }"),
796 );
797 script.push_str(
798 " check(res, { 'security:bearer': (r) => r.status >= 200 && r.status < 500 });\n",
799 );
800 script.push_str(" }\n");
801
802 let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
804 script.push_str(" {\n");
805 let api_key_hdrs = format!("{{ 'X-API-Key': '{}' }}", api_key);
806 self.emit_get(script, "${BASE_URL}/conformance/security/apikey", Some(&api_key_hdrs));
807 script.push_str(
808 " check(res, { 'security:apikey': (r) => r.status >= 200 && r.status < 500 });\n",
809 );
810 script.push_str(" }\n");
811
812 let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
814 let encoded = base64_encode(basic_creds);
815 script.push_str(" {\n");
816 let basic_hdrs = format!("{{ 'Authorization': 'Basic {}' }}", encoded);
817 self.emit_get(script, "${BASE_URL}/conformance/security/basic", Some(&basic_hdrs));
818 script.push_str(
819 " check(res, { 'security:basic': (r) => r.status >= 200 && r.status < 500 });\n",
820 );
821 script.push_str(" }\n");
822
823 script.push_str(" });\n\n");
824 }
825
826 fn generate_handle_summary(&self, script: &mut String) {
827 let report_path = match &self.config.output_dir {
830 Some(dir) => {
831 let abs = std::fs::canonicalize(dir)
832 .unwrap_or_else(|_| dir.clone())
833 .join("conformance-report.json");
834 abs.to_string_lossy().to_string()
835 }
836 None => "conformance-report.json".to_string(),
837 };
838
839 script.push_str("export function handleSummary(data) {\n");
840 script.push_str(" // Extract check results for conformance reporting\n");
841 script.push_str(" let checks = {};\n");
842 script.push_str(" if (data.metrics && data.metrics.checks) {\n");
843 script.push_str(" // Overall check pass rate\n");
844 script.push_str(" checks.overall_pass_rate = data.metrics.checks.values.rate;\n");
845 script.push_str(" }\n");
846 script.push_str(" // Collect per-check results from root_group\n");
847 script.push_str(" let checkResults = {};\n");
848 script.push_str(" function walkGroups(group) {\n");
849 script.push_str(" if (group.checks) {\n");
850 script.push_str(" for (let checkObj of group.checks) {\n");
851 script.push_str(" checkResults[checkObj.name] = {\n");
852 script.push_str(" passes: checkObj.passes,\n");
853 script.push_str(" fails: checkObj.fails,\n");
854 script.push_str(" };\n");
855 script.push_str(" }\n");
856 script.push_str(" }\n");
857 script.push_str(" if (group.groups) {\n");
858 script.push_str(" for (let subGroup of group.groups) {\n");
859 script.push_str(" walkGroups(subGroup);\n");
860 script.push_str(" }\n");
861 script.push_str(" }\n");
862 script.push_str(" }\n");
863 script.push_str(" if (data.root_group) {\n");
864 script.push_str(" walkGroups(data.root_group);\n");
865 script.push_str(" }\n");
866 script.push_str(" let result = {\n");
867 script.push_str(&format!(
868 " '{}': JSON.stringify({{ checks: checkResults, overall: checks }}, null, 2),\n",
869 report_path
870 ));
871 script.push_str(" 'summary.json': JSON.stringify(data),\n");
872 script.push_str(" stdout: textSummary(data, { indent: ' ', enableColors: true }),\n");
873 script.push_str(" };\n");
874 script.push_str(" return result;\n");
875 script.push_str("}\n\n");
876 script.push_str("// textSummary fallback\n");
877 script.push_str("function textSummary(data, opts) {\n");
878 script.push_str(" return JSON.stringify(data, null, 2);\n");
879 script.push_str("}\n");
880 }
881}
882
883fn base64_encode(input: &str) -> String {
885 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
886 let bytes = input.as_bytes();
887 let mut result = String::with_capacity(bytes.len().div_ceil(3) * 4);
888 for chunk in bytes.chunks(3) {
889 let b0 = chunk[0] as u32;
890 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
891 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
892 let triple = (b0 << 16) | (b1 << 8) | b2;
893 result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
894 result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
895 if chunk.len() > 1 {
896 result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
897 } else {
898 result.push('=');
899 }
900 if chunk.len() > 2 {
901 result.push(CHARS[(triple & 0x3F) as usize] as char);
902 } else {
903 result.push('=');
904 }
905 }
906 result
907}
908
909#[cfg(test)]
910mod tests {
911 use super::*;
912
913 #[test]
914 fn test_generate_conformance_script() {
915 let config = ConformanceConfig {
916 target_url: "http://localhost:8080".to_string(),
917 api_key: None,
918 basic_auth: None,
919 skip_tls_verify: false,
920 categories: None,
921 base_path: None,
922 custom_headers: vec![],
923 output_dir: None,
924 all_operations: false,
925 custom_checks_file: None,
926 request_delay_ms: 0,
927 custom_filter: None,
928 export_requests: false,
929 validate_requests: false,
930 };
931 let generator = ConformanceGenerator::new(config);
932 let script = generator.generate().unwrap();
933
934 assert!(script.contains("import http from 'k6/http'"));
935 assert!(script.contains("vus: 1"));
936 assert!(script.contains("iterations: 1"));
937 assert!(script.contains("group('Parameters'"));
938 assert!(script.contains("group('Request Bodies'"));
939 assert!(script.contains("group('Schema Types'"));
940 assert!(script.contains("group('Composition'"));
941 assert!(script.contains("group('String Formats'"));
942 assert!(script.contains("group('Constraints'"));
943 assert!(script.contains("group('Response Codes'"));
944 assert!(script.contains("group('HTTP Methods'"));
945 assert!(script.contains("group('Content Types'"));
946 assert!(script.contains("group('Security'"));
947 assert!(script.contains("handleSummary"));
948 }
949
950 #[test]
951 fn test_base64_encode() {
952 assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz");
953 assert_eq!(base64_encode("a"), "YQ==");
954 assert_eq!(base64_encode("ab"), "YWI=");
955 assert_eq!(base64_encode("abc"), "YWJj");
956 }
957
958 #[test]
959 fn test_conformance_script_with_custom_auth() {
960 let config = ConformanceConfig {
961 target_url: "https://api.example.com".to_string(),
962 api_key: Some("my-api-key".to_string()),
963 basic_auth: Some("admin:secret".to_string()),
964 skip_tls_verify: true,
965 categories: None,
966 base_path: None,
967 custom_headers: vec![],
968 output_dir: None,
969 all_operations: false,
970 custom_checks_file: None,
971 request_delay_ms: 0,
972 custom_filter: None,
973 export_requests: false,
974 validate_requests: false,
975 };
976 let generator = ConformanceGenerator::new(config);
977 let script = generator.generate().unwrap();
978
979 assert!(script.contains("insecureSkipTLSVerify: true"));
980 assert!(script.contains("my-api-key"));
981 assert!(script.contains(&base64_encode("admin:secret")));
982 }
983
984 #[test]
985 fn test_should_include_category_none_includes_all() {
986 let config = ConformanceConfig {
987 target_url: "http://localhost:8080".to_string(),
988 api_key: None,
989 basic_auth: None,
990 skip_tls_verify: false,
991 categories: None,
992 base_path: None,
993 custom_headers: vec![],
994 output_dir: None,
995 all_operations: false,
996 custom_checks_file: None,
997 request_delay_ms: 0,
998 custom_filter: None,
999 export_requests: false,
1000 validate_requests: false,
1001 };
1002 assert!(config.should_include_category("Parameters"));
1003 assert!(config.should_include_category("Security"));
1004 assert!(config.should_include_category("Anything"));
1005 }
1006
1007 #[test]
1008 fn test_should_include_category_filtered() {
1009 let config = ConformanceConfig {
1010 target_url: "http://localhost:8080".to_string(),
1011 api_key: None,
1012 basic_auth: None,
1013 skip_tls_verify: false,
1014 categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
1015 base_path: None,
1016 custom_headers: vec![],
1017 output_dir: None,
1018 all_operations: false,
1019 custom_checks_file: None,
1020 request_delay_ms: 0,
1021 custom_filter: None,
1022 export_requests: false,
1023 validate_requests: false,
1024 };
1025 assert!(config.should_include_category("Parameters"));
1026 assert!(config.should_include_category("Security"));
1027 assert!(config.should_include_category("parameters")); assert!(!config.should_include_category("Composition"));
1029 assert!(!config.should_include_category("Schema Types"));
1030 }
1031
1032 #[test]
1033 fn test_generate_with_category_filter() {
1034 let config = ConformanceConfig {
1035 target_url: "http://localhost:8080".to_string(),
1036 api_key: None,
1037 basic_auth: None,
1038 skip_tls_verify: false,
1039 categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
1040 base_path: None,
1041 custom_headers: vec![],
1042 output_dir: None,
1043 all_operations: false,
1044 custom_checks_file: None,
1045 request_delay_ms: 0,
1046 custom_filter: None,
1047 export_requests: false,
1048 validate_requests: false,
1049 };
1050 let generator = ConformanceGenerator::new(config);
1051 let script = generator.generate().unwrap();
1052
1053 assert!(script.contains("group('Parameters'"));
1054 assert!(script.contains("group('Security'"));
1055 assert!(!script.contains("group('Request Bodies'"));
1056 assert!(!script.contains("group('Schema Types'"));
1057 assert!(!script.contains("group('Composition'"));
1058 }
1059
1060 #[test]
1061 fn test_effective_base_url_no_base_path() {
1062 let config = ConformanceConfig {
1063 target_url: "https://example.com".to_string(),
1064 api_key: None,
1065 basic_auth: None,
1066 skip_tls_verify: false,
1067 categories: None,
1068 base_path: None,
1069 custom_headers: vec![],
1070 output_dir: None,
1071 all_operations: false,
1072 custom_checks_file: None,
1073 request_delay_ms: 0,
1074 custom_filter: None,
1075 export_requests: false,
1076 validate_requests: false,
1077 };
1078 assert_eq!(config.effective_base_url(), "https://example.com");
1079 }
1080
1081 #[test]
1082 fn test_effective_base_url_with_base_path() {
1083 let config = ConformanceConfig {
1084 target_url: "https://example.com".to_string(),
1085 api_key: None,
1086 basic_auth: None,
1087 skip_tls_verify: false,
1088 categories: None,
1089 base_path: Some("/api".to_string()),
1090 custom_headers: vec![],
1091 output_dir: None,
1092 all_operations: false,
1093 custom_checks_file: None,
1094 request_delay_ms: 0,
1095 custom_filter: None,
1096 export_requests: false,
1097 validate_requests: false,
1098 };
1099 assert_eq!(config.effective_base_url(), "https://example.com/api");
1100 }
1101
1102 #[test]
1103 fn test_effective_base_url_trailing_slash_normalization() {
1104 let config = ConformanceConfig {
1105 target_url: "https://example.com/".to_string(),
1106 api_key: None,
1107 basic_auth: None,
1108 skip_tls_verify: false,
1109 categories: None,
1110 base_path: Some("/api".to_string()),
1111 custom_headers: vec![],
1112 output_dir: None,
1113 all_operations: false,
1114 custom_checks_file: None,
1115 request_delay_ms: 0,
1116 custom_filter: None,
1117 export_requests: false,
1118 validate_requests: false,
1119 };
1120 assert_eq!(config.effective_base_url(), "https://example.com/api");
1121 }
1122
1123 #[test]
1124 fn test_effective_base_url_trailing_slash_no_base_path() {
1125 let config = ConformanceConfig {
1128 target_url: "https://192.168.2.86/".to_string(),
1129 api_key: None,
1130 basic_auth: None,
1131 skip_tls_verify: false,
1132 categories: None,
1133 base_path: None,
1134 custom_headers: vec![],
1135 output_dir: None,
1136 all_operations: false,
1137 custom_checks_file: None,
1138 request_delay_ms: 0,
1139 custom_filter: None,
1140 export_requests: false,
1141 validate_requests: false,
1142 };
1143 assert_eq!(config.effective_base_url(), "https://192.168.2.86");
1144 }
1145
1146 #[test]
1147 fn test_generate_script_with_base_path() {
1148 let config = ConformanceConfig {
1149 target_url: "https://192.168.2.86".to_string(),
1150 api_key: None,
1151 basic_auth: None,
1152 skip_tls_verify: true,
1153 categories: None,
1154 base_path: Some("/api".to_string()),
1155 custom_headers: vec![],
1156 output_dir: None,
1157 all_operations: false,
1158 custom_checks_file: None,
1159 request_delay_ms: 0,
1160 custom_filter: None,
1161 export_requests: false,
1162 validate_requests: false,
1163 };
1164 let generator = ConformanceGenerator::new(config);
1165 let script = generator.generate().unwrap();
1166
1167 assert!(script.contains("const BASE_URL = 'https://192.168.2.86/api'"));
1168 assert!(script.contains("${BASE_URL}/conformance/"));
1170 }
1171
1172 #[test]
1173 fn test_generate_with_custom_headers() {
1174 let config = ConformanceConfig {
1175 target_url: "https://192.168.2.86".to_string(),
1176 api_key: None,
1177 basic_auth: None,
1178 skip_tls_verify: true,
1179 categories: Some(vec!["Parameters".to_string()]),
1180 base_path: Some("/api".to_string()),
1181 custom_headers: vec![
1182 ("X-Avi-Tenant".to_string(), "admin".to_string()),
1183 ("X-CSRFToken".to_string(), "real-token".to_string()),
1184 ],
1185 output_dir: None,
1186 all_operations: false,
1187 custom_checks_file: None,
1188 request_delay_ms: 0,
1189 custom_filter: None,
1190 export_requests: false,
1191 validate_requests: false,
1192 };
1193 let generator = ConformanceGenerator::new(config);
1194 let script = generator.generate().unwrap();
1195
1196 assert!(
1198 !script.contains("const CUSTOM_HEADERS"),
1199 "Script should NOT declare a CUSTOM_HEADERS const"
1200 );
1201 assert!(script.contains("'X-Avi-Tenant': 'admin'"));
1202 assert!(script.contains("'X-CSRFToken': 'real-token'"));
1203 }
1204
1205 #[test]
1206 fn test_custom_headers_js_object() {
1207 let config = ConformanceConfig {
1208 target_url: "http://localhost".to_string(),
1209 api_key: None,
1210 basic_auth: None,
1211 skip_tls_verify: false,
1212 categories: None,
1213 base_path: None,
1214 custom_headers: vec![
1215 ("Authorization".to_string(), "Bearer abc123".to_string()),
1216 ("X-Custom".to_string(), "value".to_string()),
1217 ],
1218 output_dir: None,
1219 all_operations: false,
1220 custom_checks_file: None,
1221 request_delay_ms: 0,
1222 custom_filter: None,
1223 export_requests: false,
1224 validate_requests: false,
1225 };
1226 let js = config.custom_headers_js_object();
1227 assert!(js.contains("'Authorization': 'Bearer abc123'"));
1228 assert!(js.contains("'X-Custom': 'value'"));
1229 }
1230}