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