1use crate::error::{BenchError, Result};
4use std::path::{Path, PathBuf};
5
6use super::custom::CustomConformanceConfig;
7
8#[derive(Default)]
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}
37
38impl ConformanceConfig {
39 pub fn should_include_category(&self, category: &str) -> bool {
41 match &self.categories {
42 None => true,
43 Some(cats) => cats.iter().any(|c| c.eq_ignore_ascii_case(category)),
44 }
45 }
46
47 pub fn has_custom_headers(&self) -> bool {
49 !self.custom_headers.is_empty()
50 }
51
52 pub fn has_cookie_header(&self) -> bool {
56 self.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("cookie"))
57 }
58
59 pub fn custom_headers_js_object(&self) -> String {
61 let entries: Vec<String> = self
62 .custom_headers
63 .iter()
64 .map(|(k, v)| format!("'{}': '{}'", k, v.replace('\'', "\\'")))
65 .collect();
66 format!("{{ {} }}", entries.join(", "))
67 }
68
69 pub fn generate_custom_group(&self) -> Result<Option<String>> {
72 let path = match &self.custom_checks_file {
73 Some(p) => p,
74 None => return Ok(None),
75 };
76 let config = CustomConformanceConfig::from_file(path)?;
77 if config.custom_checks.is_empty() {
78 return Ok(None);
79 }
80 Ok(Some(config.generate_k6_group("BASE_URL", &self.custom_headers)))
81 }
82
83 pub fn effective_base_url(&self) -> String {
86 match &self.base_path {
87 None => self.target_url.clone(),
88 Some(bp) if bp.is_empty() => self.target_url.clone(),
89 Some(bp) => {
90 let url = self.target_url.trim_end_matches('/');
91 let path = if bp.starts_with('/') {
92 bp.as_str()
93 } else {
94 return format!("{}/{}", url, bp);
95 };
96 format!("{}{}", url, path)
97 }
98 }
99 }
100}
101
102pub struct ConformanceGenerator {
104 config: ConformanceConfig,
105}
106
107impl ConformanceGenerator {
108 pub fn new(config: ConformanceConfig) -> Self {
109 Self { config }
110 }
111
112 pub fn generate(&self) -> Result<String> {
114 let mut script = String::with_capacity(16384);
115
116 script.push_str("import http from 'k6/http';\n");
118 script.push_str("import { check, group } from 'k6';\n\n");
119
120 script.push_str(
124 "http.setResponseCallback(http.expectedStatuses({ min: 100, max: 599 }));\n\n",
125 );
126
127 script.push_str("export const options = {\n");
129 script.push_str(" vus: 1,\n");
130 script.push_str(" iterations: 1,\n");
131 if self.config.skip_tls_verify {
132 script.push_str(" insecureSkipTLSVerify: true,\n");
133 }
134 script.push_str(" thresholds: {\n");
135 script.push_str(" checks: ['rate>0'],\n");
136 script.push_str(" },\n");
137 script.push_str("};\n\n");
138
139 script.push_str(&format!("const BASE_URL = '{}';\n\n", self.config.effective_base_url()));
141
142 script.push_str("const JSON_HEADERS = { 'Content-Type': 'application/json' };\n\n");
144
145 script.push_str("export default function () {\n");
147
148 if self.config.has_cookie_header() {
149 script.push_str(
150 " // Clear cookie jar to prevent server Set-Cookie from duplicating custom Cookie header\n",
151 );
152 script.push_str(" http.cookieJar().clear(BASE_URL);\n\n");
153 }
154
155 if self.config.should_include_category("Parameters") {
156 self.generate_parameters_group(&mut script);
157 }
158 if self.config.should_include_category("Request Bodies") {
159 self.generate_request_bodies_group(&mut script);
160 }
161 if self.config.should_include_category("Schema Types") {
162 self.generate_schema_types_group(&mut script);
163 }
164 if self.config.should_include_category("Composition") {
165 self.generate_composition_group(&mut script);
166 }
167 if self.config.should_include_category("String Formats") {
168 self.generate_string_formats_group(&mut script);
169 }
170 if self.config.should_include_category("Constraints") {
171 self.generate_constraints_group(&mut script);
172 }
173 if self.config.should_include_category("Response Codes") {
174 self.generate_response_codes_group(&mut script);
175 }
176 if self.config.should_include_category("HTTP Methods") {
177 self.generate_http_methods_group(&mut script);
178 }
179 if self.config.should_include_category("Content Types") {
180 self.generate_content_negotiation_group(&mut script);
181 }
182 if self.config.should_include_category("Security") {
183 self.generate_security_group(&mut script);
184 }
185
186 if let Some(custom_group) = self.config.generate_custom_group()? {
188 script.push_str(&custom_group);
189 }
190
191 script.push_str("}\n\n");
192
193 self.generate_handle_summary(&mut script);
195
196 Ok(script)
197 }
198
199 pub fn write_script(&self, path: &Path) -> Result<()> {
201 let script = self.generate()?;
202 if let Some(parent) = path.parent() {
203 std::fs::create_dir_all(parent)?;
204 }
205 std::fs::write(path, script)
206 .map_err(|e| BenchError::Other(format!("Failed to write conformance script: {}", e)))
207 }
208
209 fn merge_with_custom_headers(&self, headers_expr: &str) -> String {
213 if self.config.has_custom_headers() {
214 format!(
215 "Object.assign({{}}, {}, {})",
216 headers_expr,
217 self.config.custom_headers_js_object()
218 )
219 } else {
220 headers_expr.to_string()
221 }
222 }
223
224 fn emit_get(&self, script: &mut String, url: &str, extra_headers: Option<&str>) {
226 let has_custom = self.config.has_custom_headers();
227 let custom_obj = self.config.custom_headers_js_object();
228 match (extra_headers, has_custom) {
229 (None, false) => {
230 script.push_str(&format!(" let res = http.get(`{}`);\n", url));
231 }
232 (None, true) => {
233 script.push_str(&format!(
234 " let res = http.get(`{}`, {{ headers: {} }});\n",
235 url, custom_obj
236 ));
237 }
238 (Some(hdrs), false) => {
239 script.push_str(&format!(
240 " let res = http.get(`{}`, {{ headers: {} }});\n",
241 url, hdrs
242 ));
243 }
244 (Some(hdrs), true) => {
245 script.push_str(&format!(
246 " let res = http.get(`{}`, {{ headers: Object.assign({{}}, {}, {}) }});\n",
247 url, hdrs, custom_obj
248 ));
249 }
250 }
251 self.maybe_clear_cookie_jar(script);
252 }
253
254 fn emit_post_like(
256 &self,
257 script: &mut String,
258 method: &str,
259 url: &str,
260 body: &str,
261 headers_expr: &str,
262 ) {
263 let merged = self.merge_with_custom_headers(headers_expr);
264 script.push_str(&format!(
265 " let res = http.{}(`{}`, {}, {{ headers: {} }});\n",
266 method, url, body, merged
267 ));
268 self.maybe_clear_cookie_jar(script);
269 }
270
271 fn emit_no_body(&self, script: &mut String, method: &str, url: &str) {
273 if self.config.has_custom_headers() {
274 script.push_str(&format!(
275 " let res = http.{}(`{}`, {{ headers: {} }});\n",
276 method,
277 url,
278 self.config.custom_headers_js_object()
279 ));
280 } else {
281 script.push_str(&format!(" let res = http.{}(`{}`);\n", method, url));
282 }
283 self.maybe_clear_cookie_jar(script);
284 }
285
286 fn maybe_clear_cookie_jar(&self, script: &mut String) {
290 if self.config.has_cookie_header() {
291 script.push_str(" http.cookieJar().clear(BASE_URL);\n");
292 }
293 }
294
295 fn generate_parameters_group(&self, script: &mut String) {
296 script.push_str(" group('Parameters', function () {\n");
297
298 script.push_str(" {\n");
300 self.emit_get(script, "${BASE_URL}/conformance/params/hello", None);
301 script.push_str(
302 " check(res, { 'param:path:string': (r) => r.status >= 200 && r.status < 500 });\n",
303 );
304 script.push_str(" }\n");
305
306 script.push_str(" {\n");
308 self.emit_get(script, "${BASE_URL}/conformance/params/42", None);
309 script.push_str(
310 " check(res, { 'param:path:integer': (r) => r.status >= 200 && r.status < 500 });\n",
311 );
312 script.push_str(" }\n");
313
314 script.push_str(" {\n");
316 self.emit_get(script, "${BASE_URL}/conformance/params/query?name=test", None);
317 script.push_str(
318 " check(res, { 'param:query:string': (r) => r.status >= 200 && r.status < 500 });\n",
319 );
320 script.push_str(" }\n");
321
322 script.push_str(" {\n");
324 self.emit_get(script, "${BASE_URL}/conformance/params/query?count=10", None);
325 script.push_str(
326 " check(res, { 'param:query:integer': (r) => r.status >= 200 && r.status < 500 });\n",
327 );
328 script.push_str(" }\n");
329
330 script.push_str(" {\n");
332 self.emit_get(script, "${BASE_URL}/conformance/params/query?tags=a&tags=b", None);
333 script.push_str(
334 " check(res, { 'param:query:array': (r) => r.status >= 200 && r.status < 500 });\n",
335 );
336 script.push_str(" }\n");
337
338 script.push_str(" {\n");
340 self.emit_get(
341 script,
342 "${BASE_URL}/conformance/params/header",
343 Some("{ 'X-Custom-Param': 'test-value' }"),
344 );
345 script.push_str(
346 " check(res, { 'param:header': (r) => r.status >= 200 && r.status < 500 });\n",
347 );
348 script.push_str(" }\n");
349
350 script.push_str(" {\n");
352 script.push_str(" let jar = http.cookieJar();\n");
353 script.push_str(" jar.set(BASE_URL, 'session', 'abc123');\n");
354 self.emit_get(script, "${BASE_URL}/conformance/params/cookie", None);
355 script.push_str(
356 " check(res, { 'param:cookie': (r) => r.status >= 200 && r.status < 500 });\n",
357 );
358 script.push_str(" }\n");
359
360 script.push_str(" });\n\n");
361 }
362
363 fn generate_request_bodies_group(&self, script: &mut String) {
364 script.push_str(" group('Request Bodies', function () {\n");
365
366 script.push_str(" {\n");
368 self.emit_post_like(
369 script,
370 "post",
371 "${BASE_URL}/conformance/body/json",
372 "JSON.stringify({ name: 'test', value: 42 })",
373 "JSON_HEADERS",
374 );
375 script.push_str(
376 " check(res, { 'body:json': (r) => r.status >= 200 && r.status < 500 });\n",
377 );
378 script.push_str(" }\n");
379
380 script.push_str(" {\n");
382 if self.config.has_custom_headers() {
383 script.push_str(&format!(
384 " let res = http.post(`${{BASE_URL}}/conformance/body/form`, {{ field1: 'value1', field2: 'value2' }}, {{ headers: {} }});\n",
385 self.config.custom_headers_js_object()
386 ));
387 } else {
388 script.push_str(
389 " let res = http.post(`${BASE_URL}/conformance/body/form`, { field1: 'value1', field2: 'value2' });\n",
390 );
391 }
392 self.maybe_clear_cookie_jar(script);
393 script.push_str(
394 " check(res, { 'body:form-urlencoded': (r) => r.status >= 200 && r.status < 500 });\n",
395 );
396 script.push_str(" }\n");
397
398 script.push_str(" {\n");
400 script.push_str(
401 " let data = { field: http.file('test content', 'test.txt', 'text/plain') };\n",
402 );
403 if self.config.has_custom_headers() {
404 script.push_str(&format!(
405 " let res = http.post(`${{BASE_URL}}/conformance/body/multipart`, data, {{ headers: {} }});\n",
406 self.config.custom_headers_js_object()
407 ));
408 } else {
409 script.push_str(
410 " let res = http.post(`${BASE_URL}/conformance/body/multipart`, data);\n",
411 );
412 }
413 self.maybe_clear_cookie_jar(script);
414 script.push_str(
415 " check(res, { 'body:multipart': (r) => r.status >= 200 && r.status < 500 });\n",
416 );
417 script.push_str(" }\n");
418
419 script.push_str(" });\n\n");
420 }
421
422 fn generate_schema_types_group(&self, script: &mut String) {
423 script.push_str(" group('Schema Types', function () {\n");
424
425 let types = [
426 ("string", r#"{ "value": "hello" }"#, "schema:string"),
427 ("integer", r#"{ "value": 42 }"#, "schema:integer"),
428 ("number", r#"{ "value": 3.14 }"#, "schema:number"),
429 ("boolean", r#"{ "value": true }"#, "schema:boolean"),
430 ("array", r#"{ "value": [1, 2, 3] }"#, "schema:array"),
431 ("object", r#"{ "value": { "nested": "data" } }"#, "schema:object"),
432 ];
433
434 for (type_name, body, check_name) in types {
435 script.push_str(" {\n");
436 let url = format!("${{BASE_URL}}/conformance/schema/{}", type_name);
437 let body_str = format!("'{}'", body);
438 self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
439 script.push_str(&format!(
440 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
441 check_name
442 ));
443 script.push_str(" }\n");
444 }
445
446 script.push_str(" });\n\n");
447 }
448
449 fn generate_composition_group(&self, script: &mut String) {
450 script.push_str(" group('Composition', function () {\n");
451
452 let compositions = [
453 ("oneOf", r#"{ "type": "string", "value": "test" }"#, "composition:oneOf"),
454 ("anyOf", r#"{ "value": "test" }"#, "composition:anyOf"),
455 ("allOf", r#"{ "name": "test", "id": 1 }"#, "composition:allOf"),
456 ];
457
458 for (kind, body, check_name) in compositions {
459 script.push_str(" {\n");
460 let url = format!("${{BASE_URL}}/conformance/composition/{}", kind);
461 let body_str = format!("'{}'", body);
462 self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
463 script.push_str(&format!(
464 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
465 check_name
466 ));
467 script.push_str(" }\n");
468 }
469
470 script.push_str(" });\n\n");
471 }
472
473 fn generate_string_formats_group(&self, script: &mut String) {
474 script.push_str(" group('String Formats', function () {\n");
475
476 let formats = [
477 ("date", r#"{ "value": "2024-01-15" }"#, "format:date"),
478 ("date-time", r#"{ "value": "2024-01-15T10:30:00Z" }"#, "format:date-time"),
479 ("email", r#"{ "value": "test@example.com" }"#, "format:email"),
480 ("uuid", r#"{ "value": "550e8400-e29b-41d4-a716-446655440000" }"#, "format:uuid"),
481 ("uri", r#"{ "value": "https://example.com/path" }"#, "format:uri"),
482 ("ipv4", r#"{ "value": "192.168.1.1" }"#, "format:ipv4"),
483 ("ipv6", r#"{ "value": "::1" }"#, "format:ipv6"),
484 ];
485
486 for (fmt, body, check_name) in formats {
487 script.push_str(" {\n");
488 let url = format!("${{BASE_URL}}/conformance/formats/{}", fmt);
489 let body_str = format!("'{}'", body);
490 self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
491 script.push_str(&format!(
492 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
493 check_name
494 ));
495 script.push_str(" }\n");
496 }
497
498 script.push_str(" });\n\n");
499 }
500
501 fn generate_constraints_group(&self, script: &mut String) {
502 script.push_str(" group('Constraints', function () {\n");
503
504 let constraints = [
505 (
506 "required",
507 "JSON.stringify({ required_field: 'present' })",
508 "constraint:required",
509 ),
510 ("optional", "JSON.stringify({})", "constraint:optional"),
511 ("minmax", "JSON.stringify({ value: 50 })", "constraint:minmax"),
512 ("pattern", "JSON.stringify({ value: 'ABC-123' })", "constraint:pattern"),
513 ("enum", "JSON.stringify({ status: 'active' })", "constraint:enum"),
514 ];
515
516 for (kind, body, check_name) in constraints {
517 script.push_str(" {\n");
518 let url = format!("${{BASE_URL}}/conformance/constraints/{}", kind);
519 self.emit_post_like(script, "post", &url, body, "JSON_HEADERS");
520 script.push_str(&format!(
521 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
522 check_name
523 ));
524 script.push_str(" }\n");
525 }
526
527 script.push_str(" });\n\n");
528 }
529
530 fn generate_response_codes_group(&self, script: &mut String) {
531 script.push_str(" group('Response Codes', function () {\n");
532
533 let codes = [
534 ("200", "response:200"),
535 ("201", "response:201"),
536 ("204", "response:204"),
537 ("400", "response:400"),
538 ("404", "response:404"),
539 ];
540
541 for (code, check_name) in codes {
542 script.push_str(" {\n");
543 let url = format!("${{BASE_URL}}/conformance/responses/{}", code);
544 self.emit_get(script, &url, None);
545 script.push_str(&format!(
546 " check(res, {{ '{}': (r) => r.status === {} }});\n",
547 check_name, code
548 ));
549 script.push_str(" }\n");
550 }
551
552 script.push_str(" });\n\n");
553 }
554
555 fn generate_http_methods_group(&self, script: &mut String) {
556 script.push_str(" group('HTTP Methods', function () {\n");
557
558 script.push_str(" {\n");
560 self.emit_get(script, "${BASE_URL}/conformance/methods", None);
561 script.push_str(
562 " check(res, { 'method:GET': (r) => r.status >= 200 && r.status < 500 });\n",
563 );
564 script.push_str(" }\n");
565
566 script.push_str(" {\n");
568 self.emit_post_like(
569 script,
570 "post",
571 "${BASE_URL}/conformance/methods",
572 "JSON.stringify({ action: 'create' })",
573 "JSON_HEADERS",
574 );
575 script.push_str(
576 " check(res, { 'method:POST': (r) => r.status >= 200 && r.status < 500 });\n",
577 );
578 script.push_str(" }\n");
579
580 script.push_str(" {\n");
582 self.emit_post_like(
583 script,
584 "put",
585 "${BASE_URL}/conformance/methods",
586 "JSON.stringify({ action: 'update' })",
587 "JSON_HEADERS",
588 );
589 script.push_str(
590 " check(res, { 'method:PUT': (r) => r.status >= 200 && r.status < 500 });\n",
591 );
592 script.push_str(" }\n");
593
594 script.push_str(" {\n");
596 self.emit_post_like(
597 script,
598 "patch",
599 "${BASE_URL}/conformance/methods",
600 "JSON.stringify({ action: 'patch' })",
601 "JSON_HEADERS",
602 );
603 script.push_str(
604 " check(res, { 'method:PATCH': (r) => r.status >= 200 && r.status < 500 });\n",
605 );
606 script.push_str(" }\n");
607
608 script.push_str(" {\n");
610 self.emit_no_body(script, "del", "${BASE_URL}/conformance/methods");
611 script.push_str(
612 " check(res, { 'method:DELETE': (r) => r.status >= 200 && r.status < 500 });\n",
613 );
614 script.push_str(" }\n");
615
616 script.push_str(" {\n");
618 self.emit_no_body(script, "head", "${BASE_URL}/conformance/methods");
619 script.push_str(
620 " check(res, { 'method:HEAD': (r) => r.status >= 200 && r.status < 500 });\n",
621 );
622 script.push_str(" }\n");
623
624 script.push_str(" {\n");
626 self.emit_no_body(script, "options", "${BASE_URL}/conformance/methods");
627 script.push_str(
628 " check(res, { 'method:OPTIONS': (r) => r.status >= 200 && r.status < 500 });\n",
629 );
630 script.push_str(" }\n");
631
632 script.push_str(" });\n\n");
633 }
634
635 fn generate_content_negotiation_group(&self, script: &mut String) {
636 script.push_str(" group('Content Types', function () {\n");
637
638 script.push_str(" {\n");
639 self.emit_get(
640 script,
641 "${BASE_URL}/conformance/content-types",
642 Some("{ 'Accept': 'application/json' }"),
643 );
644 script.push_str(
645 " check(res, { 'content:negotiation': (r) => r.status >= 200 && r.status < 500 });\n",
646 );
647 script.push_str(" }\n");
648
649 script.push_str(" });\n\n");
650 }
651
652 fn generate_security_group(&self, script: &mut String) {
653 script.push_str(" group('Security', function () {\n");
654
655 script.push_str(" {\n");
657 self.emit_get(
658 script,
659 "${BASE_URL}/conformance/security/bearer",
660 Some("{ 'Authorization': 'Bearer test-token-123' }"),
661 );
662 script.push_str(
663 " check(res, { 'security:bearer': (r) => r.status >= 200 && r.status < 500 });\n",
664 );
665 script.push_str(" }\n");
666
667 let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
669 script.push_str(" {\n");
670 let api_key_hdrs = format!("{{ 'X-API-Key': '{}' }}", api_key);
671 self.emit_get(script, "${BASE_URL}/conformance/security/apikey", Some(&api_key_hdrs));
672 script.push_str(
673 " check(res, { 'security:apikey': (r) => r.status >= 200 && r.status < 500 });\n",
674 );
675 script.push_str(" }\n");
676
677 let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
679 let encoded = base64_encode(basic_creds);
680 script.push_str(" {\n");
681 let basic_hdrs = format!("{{ 'Authorization': 'Basic {}' }}", encoded);
682 self.emit_get(script, "${BASE_URL}/conformance/security/basic", Some(&basic_hdrs));
683 script.push_str(
684 " check(res, { 'security:basic': (r) => r.status >= 200 && r.status < 500 });\n",
685 );
686 script.push_str(" }\n");
687
688 script.push_str(" });\n\n");
689 }
690
691 fn generate_handle_summary(&self, script: &mut String) {
692 let report_path = match &self.config.output_dir {
695 Some(dir) => {
696 let abs = std::fs::canonicalize(dir)
697 .unwrap_or_else(|_| dir.clone())
698 .join("conformance-report.json");
699 abs.to_string_lossy().to_string()
700 }
701 None => "conformance-report.json".to_string(),
702 };
703
704 script.push_str("export function handleSummary(data) {\n");
705 script.push_str(" // Extract check results for conformance reporting\n");
706 script.push_str(" let checks = {};\n");
707 script.push_str(" if (data.metrics && data.metrics.checks) {\n");
708 script.push_str(" // Overall check pass rate\n");
709 script.push_str(" checks.overall_pass_rate = data.metrics.checks.values.rate;\n");
710 script.push_str(" }\n");
711 script.push_str(" // Collect per-check results from root_group\n");
712 script.push_str(" let checkResults = {};\n");
713 script.push_str(" function walkGroups(group) {\n");
714 script.push_str(" if (group.checks) {\n");
715 script.push_str(" for (let checkObj of group.checks) {\n");
716 script.push_str(" checkResults[checkObj.name] = {\n");
717 script.push_str(" passes: checkObj.passes,\n");
718 script.push_str(" fails: checkObj.fails,\n");
719 script.push_str(" };\n");
720 script.push_str(" }\n");
721 script.push_str(" }\n");
722 script.push_str(" if (group.groups) {\n");
723 script.push_str(" for (let subGroup of group.groups) {\n");
724 script.push_str(" walkGroups(subGroup);\n");
725 script.push_str(" }\n");
726 script.push_str(" }\n");
727 script.push_str(" }\n");
728 script.push_str(" if (data.root_group) {\n");
729 script.push_str(" walkGroups(data.root_group);\n");
730 script.push_str(" }\n");
731 script.push_str(" return {\n");
732 script.push_str(&format!(
733 " '{}': JSON.stringify({{ checks: checkResults, overall: checks }}, null, 2),\n",
734 report_path
735 ));
736 script.push_str(" stdout: textSummary(data, { indent: ' ', enableColors: true }),\n");
737 script.push_str(" };\n");
738 script.push_str("}\n\n");
739 script.push_str("// textSummary fallback\n");
740 script.push_str("function textSummary(data, opts) {\n");
741 script.push_str(" return JSON.stringify(data, null, 2);\n");
742 script.push_str("}\n");
743 }
744}
745
746fn base64_encode(input: &str) -> String {
748 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
749 let bytes = input.as_bytes();
750 let mut result = String::with_capacity(bytes.len().div_ceil(3) * 4);
751 for chunk in bytes.chunks(3) {
752 let b0 = chunk[0] as u32;
753 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
754 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
755 let triple = (b0 << 16) | (b1 << 8) | b2;
756 result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
757 result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
758 if chunk.len() > 1 {
759 result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
760 } else {
761 result.push('=');
762 }
763 if chunk.len() > 2 {
764 result.push(CHARS[(triple & 0x3F) as usize] as char);
765 } else {
766 result.push('=');
767 }
768 }
769 result
770}
771
772#[cfg(test)]
773mod tests {
774 use super::*;
775
776 #[test]
777 fn test_generate_conformance_script() {
778 let config = ConformanceConfig {
779 target_url: "http://localhost:8080".to_string(),
780 api_key: None,
781 basic_auth: None,
782 skip_tls_verify: false,
783 categories: None,
784 base_path: None,
785 custom_headers: vec![],
786 output_dir: None,
787 all_operations: false,
788 custom_checks_file: None,
789 };
790 let generator = ConformanceGenerator::new(config);
791 let script = generator.generate().unwrap();
792
793 assert!(script.contains("import http from 'k6/http'"));
794 assert!(script.contains("vus: 1"));
795 assert!(script.contains("iterations: 1"));
796 assert!(script.contains("group('Parameters'"));
797 assert!(script.contains("group('Request Bodies'"));
798 assert!(script.contains("group('Schema Types'"));
799 assert!(script.contains("group('Composition'"));
800 assert!(script.contains("group('String Formats'"));
801 assert!(script.contains("group('Constraints'"));
802 assert!(script.contains("group('Response Codes'"));
803 assert!(script.contains("group('HTTP Methods'"));
804 assert!(script.contains("group('Content Types'"));
805 assert!(script.contains("group('Security'"));
806 assert!(script.contains("handleSummary"));
807 }
808
809 #[test]
810 fn test_base64_encode() {
811 assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz");
812 assert_eq!(base64_encode("a"), "YQ==");
813 assert_eq!(base64_encode("ab"), "YWI=");
814 assert_eq!(base64_encode("abc"), "YWJj");
815 }
816
817 #[test]
818 fn test_conformance_script_with_custom_auth() {
819 let config = ConformanceConfig {
820 target_url: "https://api.example.com".to_string(),
821 api_key: Some("my-api-key".to_string()),
822 basic_auth: Some("admin:secret".to_string()),
823 skip_tls_verify: true,
824 categories: None,
825 base_path: None,
826 custom_headers: vec![],
827 output_dir: None,
828 all_operations: false,
829 custom_checks_file: None,
830 };
831 let generator = ConformanceGenerator::new(config);
832 let script = generator.generate().unwrap();
833
834 assert!(script.contains("insecureSkipTLSVerify: true"));
835 assert!(script.contains("my-api-key"));
836 assert!(script.contains(&base64_encode("admin:secret")));
837 }
838
839 #[test]
840 fn test_should_include_category_none_includes_all() {
841 let config = ConformanceConfig {
842 target_url: "http://localhost:8080".to_string(),
843 api_key: None,
844 basic_auth: None,
845 skip_tls_verify: false,
846 categories: None,
847 base_path: None,
848 custom_headers: vec![],
849 output_dir: None,
850 all_operations: false,
851 custom_checks_file: None,
852 };
853 assert!(config.should_include_category("Parameters"));
854 assert!(config.should_include_category("Security"));
855 assert!(config.should_include_category("Anything"));
856 }
857
858 #[test]
859 fn test_should_include_category_filtered() {
860 let config = ConformanceConfig {
861 target_url: "http://localhost:8080".to_string(),
862 api_key: None,
863 basic_auth: None,
864 skip_tls_verify: false,
865 categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
866 base_path: None,
867 custom_headers: vec![],
868 output_dir: None,
869 all_operations: false,
870 custom_checks_file: None,
871 };
872 assert!(config.should_include_category("Parameters"));
873 assert!(config.should_include_category("Security"));
874 assert!(config.should_include_category("parameters")); assert!(!config.should_include_category("Composition"));
876 assert!(!config.should_include_category("Schema Types"));
877 }
878
879 #[test]
880 fn test_generate_with_category_filter() {
881 let config = ConformanceConfig {
882 target_url: "http://localhost:8080".to_string(),
883 api_key: None,
884 basic_auth: None,
885 skip_tls_verify: false,
886 categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
887 base_path: None,
888 custom_headers: vec![],
889 output_dir: None,
890 all_operations: false,
891 custom_checks_file: None,
892 };
893 let generator = ConformanceGenerator::new(config);
894 let script = generator.generate().unwrap();
895
896 assert!(script.contains("group('Parameters'"));
897 assert!(script.contains("group('Security'"));
898 assert!(!script.contains("group('Request Bodies'"));
899 assert!(!script.contains("group('Schema Types'"));
900 assert!(!script.contains("group('Composition'"));
901 }
902
903 #[test]
904 fn test_effective_base_url_no_base_path() {
905 let config = ConformanceConfig {
906 target_url: "https://example.com".to_string(),
907 api_key: None,
908 basic_auth: None,
909 skip_tls_verify: false,
910 categories: None,
911 base_path: None,
912 custom_headers: vec![],
913 output_dir: None,
914 all_operations: false,
915 custom_checks_file: None,
916 };
917 assert_eq!(config.effective_base_url(), "https://example.com");
918 }
919
920 #[test]
921 fn test_effective_base_url_with_base_path() {
922 let config = ConformanceConfig {
923 target_url: "https://example.com".to_string(),
924 api_key: None,
925 basic_auth: None,
926 skip_tls_verify: false,
927 categories: None,
928 base_path: Some("/api".to_string()),
929 custom_headers: vec![],
930 output_dir: None,
931 all_operations: false,
932 custom_checks_file: None,
933 };
934 assert_eq!(config.effective_base_url(), "https://example.com/api");
935 }
936
937 #[test]
938 fn test_effective_base_url_trailing_slash_normalization() {
939 let config = ConformanceConfig {
940 target_url: "https://example.com/".to_string(),
941 api_key: None,
942 basic_auth: None,
943 skip_tls_verify: false,
944 categories: None,
945 base_path: Some("/api".to_string()),
946 custom_headers: vec![],
947 output_dir: None,
948 all_operations: false,
949 custom_checks_file: None,
950 };
951 assert_eq!(config.effective_base_url(), "https://example.com/api");
952 }
953
954 #[test]
955 fn test_generate_script_with_base_path() {
956 let config = ConformanceConfig {
957 target_url: "https://192.168.2.86".to_string(),
958 api_key: None,
959 basic_auth: None,
960 skip_tls_verify: true,
961 categories: None,
962 base_path: Some("/api".to_string()),
963 custom_headers: vec![],
964 output_dir: None,
965 all_operations: false,
966 custom_checks_file: None,
967 };
968 let generator = ConformanceGenerator::new(config);
969 let script = generator.generate().unwrap();
970
971 assert!(script.contains("const BASE_URL = 'https://192.168.2.86/api'"));
972 assert!(script.contains("${BASE_URL}/conformance/"));
974 }
975
976 #[test]
977 fn test_generate_with_custom_headers() {
978 let config = ConformanceConfig {
979 target_url: "https://192.168.2.86".to_string(),
980 api_key: None,
981 basic_auth: None,
982 skip_tls_verify: true,
983 categories: Some(vec!["Parameters".to_string()]),
984 base_path: Some("/api".to_string()),
985 custom_headers: vec![
986 ("X-Avi-Tenant".to_string(), "admin".to_string()),
987 ("X-CSRFToken".to_string(), "real-token".to_string()),
988 ],
989 output_dir: None,
990 all_operations: false,
991 custom_checks_file: None,
992 };
993 let generator = ConformanceGenerator::new(config);
994 let script = generator.generate().unwrap();
995
996 assert!(
998 !script.contains("const CUSTOM_HEADERS"),
999 "Script should NOT declare a CUSTOM_HEADERS const"
1000 );
1001 assert!(script.contains("'X-Avi-Tenant': 'admin'"));
1002 assert!(script.contains("'X-CSRFToken': 'real-token'"));
1003 }
1004
1005 #[test]
1006 fn test_custom_headers_js_object() {
1007 let config = ConformanceConfig {
1008 target_url: "http://localhost".to_string(),
1009 api_key: None,
1010 basic_auth: None,
1011 skip_tls_verify: false,
1012 categories: None,
1013 base_path: None,
1014 custom_headers: vec![
1015 ("Authorization".to_string(), "Bearer abc123".to_string()),
1016 ("X-Custom".to_string(), "value".to_string()),
1017 ],
1018 output_dir: None,
1019 all_operations: false,
1020 custom_checks_file: None,
1021 };
1022 let js = config.custom_headers_js_object();
1023 assert!(js.contains("'Authorization': 'Bearer abc123'"));
1024 assert!(js.contains("'X-Custom': 'value'"));
1025 }
1026}