1use crate::error::{BenchError, Result};
4use std::path::Path;
5
6pub struct ConformanceConfig {
8 pub target_url: String,
10 pub api_key: Option<String>,
12 pub basic_auth: Option<String>,
14 pub skip_tls_verify: bool,
16}
17
18pub struct ConformanceGenerator {
20 config: ConformanceConfig,
21}
22
23impl ConformanceGenerator {
24 pub fn new(config: ConformanceConfig) -> Self {
25 Self { config }
26 }
27
28 pub fn generate(&self) -> Result<String> {
30 let mut script = String::with_capacity(16384);
31
32 script.push_str("import http from 'k6/http';\n");
34 script.push_str("import { check, group } from 'k6';\n\n");
35
36 script.push_str("export const options = {\n");
38 script.push_str(" vus: 1,\n");
39 script.push_str(" iterations: 1,\n");
40 if self.config.skip_tls_verify {
41 script.push_str(" insecureSkipTLSVerify: true,\n");
42 }
43 script.push_str(" thresholds: {\n");
44 script.push_str(" checks: ['rate>0'],\n");
45 script.push_str(" },\n");
46 script.push_str("};\n\n");
47
48 script.push_str(&format!("const BASE_URL = '{}';\n\n", self.config.target_url));
50
51 script.push_str("const JSON_HEADERS = { 'Content-Type': 'application/json' };\n\n");
53
54 script.push_str("export default function () {\n");
56
57 self.generate_parameters_group(&mut script);
59 self.generate_request_bodies_group(&mut script);
61 self.generate_schema_types_group(&mut script);
63 self.generate_composition_group(&mut script);
65 self.generate_string_formats_group(&mut script);
67 self.generate_constraints_group(&mut script);
69 self.generate_response_codes_group(&mut script);
71 self.generate_http_methods_group(&mut script);
73 self.generate_content_negotiation_group(&mut script);
75 self.generate_security_group(&mut script);
77
78 script.push_str("}\n\n");
79
80 self.generate_handle_summary(&mut script);
82
83 Ok(script)
84 }
85
86 pub fn write_script(&self, path: &Path) -> Result<()> {
88 let script = self.generate()?;
89 if let Some(parent) = path.parent() {
90 std::fs::create_dir_all(parent)?;
91 }
92 std::fs::write(path, script)
93 .map_err(|e| BenchError::Other(format!("Failed to write conformance script: {}", e)))
94 }
95
96 fn generate_parameters_group(&self, script: &mut String) {
97 script.push_str(" group('Parameters', function () {\n");
98
99 script.push_str(" {\n");
101 script.push_str(" let res = http.get(`${BASE_URL}/conformance/params/hello`);\n");
102 script.push_str(
103 " check(res, { 'param:path:string': (r) => r.status >= 200 && r.status < 500 });\n",
104 );
105 script.push_str(" }\n");
106
107 script.push_str(" {\n");
109 script.push_str(" let res = http.get(`${BASE_URL}/conformance/params/42`);\n");
110 script.push_str(
111 " check(res, { 'param:path:integer': (r) => r.status >= 200 && r.status < 500 });\n",
112 );
113 script.push_str(" }\n");
114
115 script.push_str(" {\n");
117 script.push_str(
118 " let res = http.get(`${BASE_URL}/conformance/params/query?name=test`);\n",
119 );
120 script.push_str(
121 " check(res, { 'param:query:string': (r) => r.status >= 200 && r.status < 500 });\n",
122 );
123 script.push_str(" }\n");
124
125 script.push_str(" {\n");
127 script.push_str(
128 " let res = http.get(`${BASE_URL}/conformance/params/query?count=10`);\n",
129 );
130 script.push_str(
131 " check(res, { 'param:query:integer': (r) => r.status >= 200 && r.status < 500 });\n",
132 );
133 script.push_str(" }\n");
134
135 script.push_str(" {\n");
137 script.push_str(
138 " let res = http.get(`${BASE_URL}/conformance/params/query?tags=a&tags=b`);\n",
139 );
140 script.push_str(
141 " check(res, { 'param:query:array': (r) => r.status >= 200 && r.status < 500 });\n",
142 );
143 script.push_str(" }\n");
144
145 script.push_str(" {\n");
147 script.push_str(
148 " let res = http.get(`${BASE_URL}/conformance/params/header`, { headers: { 'X-Custom-Param': 'test-value' } });\n",
149 );
150 script.push_str(
151 " check(res, { 'param:header': (r) => r.status >= 200 && r.status < 500 });\n",
152 );
153 script.push_str(" }\n");
154
155 script.push_str(" {\n");
157 script.push_str(" let jar = http.cookieJar();\n");
158 script.push_str(" jar.set(BASE_URL, 'session', 'abc123');\n");
159 script.push_str(" let res = http.get(`${BASE_URL}/conformance/params/cookie`);\n");
160 script.push_str(
161 " check(res, { 'param:cookie': (r) => r.status >= 200 && r.status < 500 });\n",
162 );
163 script.push_str(" }\n");
164
165 script.push_str(" });\n\n");
166 }
167
168 fn generate_request_bodies_group(&self, script: &mut String) {
169 script.push_str(" group('Request Bodies', function () {\n");
170
171 script.push_str(" {\n");
173 script.push_str(
174 " let res = http.post(`${BASE_URL}/conformance/body/json`, JSON.stringify({ name: 'test', value: 42 }), { headers: JSON_HEADERS });\n",
175 );
176 script.push_str(
177 " check(res, { 'body:json': (r) => r.status >= 200 && r.status < 500 });\n",
178 );
179 script.push_str(" }\n");
180
181 script.push_str(" {\n");
183 script.push_str(
184 " let res = http.post(`${BASE_URL}/conformance/body/form`, { field1: 'value1', field2: 'value2' });\n",
185 );
186 script.push_str(
187 " check(res, { 'body:form-urlencoded': (r) => r.status >= 200 && r.status < 500 });\n",
188 );
189 script.push_str(" }\n");
190
191 script.push_str(" {\n");
193 script.push_str(
194 " let data = { field: http.file('test content', 'test.txt', 'text/plain') };\n",
195 );
196 script.push_str(
197 " let res = http.post(`${BASE_URL}/conformance/body/multipart`, data);\n",
198 );
199 script.push_str(
200 " check(res, { 'body:multipart': (r) => r.status >= 200 && r.status < 500 });\n",
201 );
202 script.push_str(" }\n");
203
204 script.push_str(" });\n\n");
205 }
206
207 fn generate_schema_types_group(&self, script: &mut String) {
208 script.push_str(" group('Schema Types', function () {\n");
209
210 let types = [
211 ("string", r#"{ "value": "hello" }"#, "schema:string"),
212 ("integer", r#"{ "value": 42 }"#, "schema:integer"),
213 ("number", r#"{ "value": 3.14 }"#, "schema:number"),
214 ("boolean", r#"{ "value": true }"#, "schema:boolean"),
215 ("array", r#"{ "value": [1, 2, 3] }"#, "schema:array"),
216 ("object", r#"{ "value": { "nested": "data" } }"#, "schema:object"),
217 ];
218
219 for (type_name, body, check_name) in types {
220 script.push_str(" {\n");
221 script.push_str(&format!(
222 " let res = http.post(`${{BASE_URL}}/conformance/schema/{}`, '{}', {{ headers: JSON_HEADERS }});\n",
223 type_name, body
224 ));
225 script.push_str(&format!(
226 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
227 check_name
228 ));
229 script.push_str(" }\n");
230 }
231
232 script.push_str(" });\n\n");
233 }
234
235 fn generate_composition_group(&self, script: &mut String) {
236 script.push_str(" group('Composition', function () {\n");
237
238 let compositions = [
239 ("oneOf", r#"{ "type": "string", "value": "test" }"#, "composition:oneOf"),
240 ("anyOf", r#"{ "value": "test" }"#, "composition:anyOf"),
241 ("allOf", r#"{ "name": "test", "id": 1 }"#, "composition:allOf"),
242 ];
243
244 for (kind, body, check_name) in compositions {
245 script.push_str(" {\n");
246 script.push_str(&format!(
247 " let res = http.post(`${{BASE_URL}}/conformance/composition/{}`, '{}', {{ headers: JSON_HEADERS }});\n",
248 kind, body
249 ));
250 script.push_str(&format!(
251 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
252 check_name
253 ));
254 script.push_str(" }\n");
255 }
256
257 script.push_str(" });\n\n");
258 }
259
260 fn generate_string_formats_group(&self, script: &mut String) {
261 script.push_str(" group('String Formats', function () {\n");
262
263 let formats = [
264 ("date", r#"{ "value": "2024-01-15" }"#, "format:date"),
265 ("date-time", r#"{ "value": "2024-01-15T10:30:00Z" }"#, "format:date-time"),
266 ("email", r#"{ "value": "test@example.com" }"#, "format:email"),
267 ("uuid", r#"{ "value": "550e8400-e29b-41d4-a716-446655440000" }"#, "format:uuid"),
268 ("uri", r#"{ "value": "https://example.com/path" }"#, "format:uri"),
269 ("ipv4", r#"{ "value": "192.168.1.1" }"#, "format:ipv4"),
270 ("ipv6", r#"{ "value": "::1" }"#, "format:ipv6"),
271 ];
272
273 for (fmt, body, check_name) in formats {
274 script.push_str(" {\n");
275 script.push_str(&format!(
276 " let res = http.post(`${{BASE_URL}}/conformance/formats/{}`, '{}', {{ headers: JSON_HEADERS }});\n",
277 fmt, body
278 ));
279 script.push_str(&format!(
280 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
281 check_name
282 ));
283 script.push_str(" }\n");
284 }
285
286 script.push_str(" });\n\n");
287 }
288
289 fn generate_constraints_group(&self, script: &mut String) {
290 script.push_str(" group('Constraints', function () {\n");
291
292 script.push_str(" {\n");
294 script.push_str(
295 " let res = http.post(`${BASE_URL}/conformance/constraints/required`, JSON.stringify({ required_field: 'present' }), { headers: JSON_HEADERS });\n",
296 );
297 script.push_str(
298 " check(res, { 'constraint:required': (r) => r.status >= 200 && r.status < 500 });\n",
299 );
300 script.push_str(" }\n");
301
302 script.push_str(" {\n");
304 script.push_str(
305 " let res = http.post(`${BASE_URL}/conformance/constraints/optional`, JSON.stringify({}), { headers: JSON_HEADERS });\n",
306 );
307 script.push_str(
308 " check(res, { 'constraint:optional': (r) => r.status >= 200 && r.status < 500 });\n",
309 );
310 script.push_str(" }\n");
311
312 script.push_str(" {\n");
314 script.push_str(
315 " let res = http.post(`${BASE_URL}/conformance/constraints/minmax`, JSON.stringify({ value: 50 }), { headers: JSON_HEADERS });\n",
316 );
317 script.push_str(
318 " check(res, { 'constraint:minmax': (r) => r.status >= 200 && r.status < 500 });\n",
319 );
320 script.push_str(" }\n");
321
322 script.push_str(" {\n");
324 script.push_str(
325 " let res = http.post(`${BASE_URL}/conformance/constraints/pattern`, JSON.stringify({ value: 'ABC-123' }), { headers: JSON_HEADERS });\n",
326 );
327 script.push_str(
328 " check(res, { 'constraint:pattern': (r) => r.status >= 200 && r.status < 500 });\n",
329 );
330 script.push_str(" }\n");
331
332 script.push_str(" {\n");
334 script.push_str(
335 " let res = http.post(`${BASE_URL}/conformance/constraints/enum`, JSON.stringify({ status: 'active' }), { headers: JSON_HEADERS });\n",
336 );
337 script.push_str(
338 " check(res, { 'constraint:enum': (r) => r.status >= 200 && r.status < 500 });\n",
339 );
340 script.push_str(" }\n");
341
342 script.push_str(" });\n\n");
343 }
344
345 fn generate_response_codes_group(&self, script: &mut String) {
346 script.push_str(" group('Response Codes', function () {\n");
347
348 let codes = [
349 ("200", "response:200"),
350 ("201", "response:201"),
351 ("204", "response:204"),
352 ("400", "response:400"),
353 ("404", "response:404"),
354 ];
355
356 for (code, check_name) in codes {
357 script.push_str(" {\n");
358 script.push_str(&format!(
359 " let res = http.get(`${{BASE_URL}}/conformance/responses/{}`);\n",
360 code
361 ));
362 script.push_str(&format!(
363 " check(res, {{ '{}': (r) => r.status === {} }});\n",
364 check_name, code
365 ));
366 script.push_str(" }\n");
367 }
368
369 script.push_str(" });\n\n");
370 }
371
372 fn generate_http_methods_group(&self, script: &mut String) {
373 script.push_str(" group('HTTP Methods', function () {\n");
374
375 script.push_str(" {\n");
377 script.push_str(" let res = http.get(`${BASE_URL}/conformance/methods`);\n");
378 script.push_str(
379 " check(res, { 'method:GET': (r) => r.status >= 200 && r.status < 500 });\n",
380 );
381 script.push_str(" }\n");
382
383 script.push_str(" {\n");
385 script.push_str(
386 " let res = http.post(`${BASE_URL}/conformance/methods`, JSON.stringify({ action: 'create' }), { headers: JSON_HEADERS });\n",
387 );
388 script.push_str(
389 " check(res, { 'method:POST': (r) => r.status >= 200 && r.status < 500 });\n",
390 );
391 script.push_str(" }\n");
392
393 script.push_str(" {\n");
395 script.push_str(
396 " let res = http.put(`${BASE_URL}/conformance/methods`, JSON.stringify({ action: 'update' }), { headers: JSON_HEADERS });\n",
397 );
398 script.push_str(
399 " check(res, { 'method:PUT': (r) => r.status >= 200 && r.status < 500 });\n",
400 );
401 script.push_str(" }\n");
402
403 script.push_str(" {\n");
405 script.push_str(
406 " let res = http.patch(`${BASE_URL}/conformance/methods`, JSON.stringify({ action: 'patch' }), { headers: JSON_HEADERS });\n",
407 );
408 script.push_str(
409 " check(res, { 'method:PATCH': (r) => r.status >= 200 && r.status < 500 });\n",
410 );
411 script.push_str(" }\n");
412
413 script.push_str(" {\n");
415 script.push_str(" let res = http.del(`${BASE_URL}/conformance/methods`);\n");
416 script.push_str(
417 " check(res, { 'method:DELETE': (r) => r.status >= 200 && r.status < 500 });\n",
418 );
419 script.push_str(" }\n");
420
421 script.push_str(" {\n");
423 script.push_str(" let res = http.head(`${BASE_URL}/conformance/methods`);\n");
424 script.push_str(
425 " check(res, { 'method:HEAD': (r) => r.status >= 200 && r.status < 500 });\n",
426 );
427 script.push_str(" }\n");
428
429 script.push_str(" {\n");
431 script.push_str(" let res = http.options(`${BASE_URL}/conformance/methods`);\n");
432 script.push_str(
433 " check(res, { 'method:OPTIONS': (r) => r.status >= 200 && r.status < 500 });\n",
434 );
435 script.push_str(" }\n");
436
437 script.push_str(" });\n\n");
438 }
439
440 fn generate_content_negotiation_group(&self, script: &mut String) {
441 script.push_str(" group('Content Types', function () {\n");
442
443 script.push_str(" {\n");
444 script.push_str(
445 " let res = http.get(`${BASE_URL}/conformance/content-types`, { headers: { 'Accept': 'application/json' } });\n",
446 );
447 script.push_str(
448 " check(res, { 'content:negotiation': (r) => r.status >= 200 && r.status < 500 });\n",
449 );
450 script.push_str(" }\n");
451
452 script.push_str(" });\n\n");
453 }
454
455 fn generate_security_group(&self, script: &mut String) {
456 script.push_str(" group('Security', function () {\n");
457
458 script.push_str(" {\n");
460 script.push_str(
461 " let res = http.get(`${BASE_URL}/conformance/security/bearer`, { headers: { 'Authorization': 'Bearer test-token-123' } });\n",
462 );
463 script.push_str(
464 " check(res, { 'security:bearer': (r) => r.status >= 200 && r.status < 500 });\n",
465 );
466 script.push_str(" }\n");
467
468 let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
470 script.push_str(" {\n");
471 script.push_str(&format!(
472 " let res = http.get(`${{BASE_URL}}/conformance/security/apikey`, {{ headers: {{ 'X-API-Key': '{}' }} }});\n",
473 api_key
474 ));
475 script.push_str(
476 " check(res, { 'security:apikey': (r) => r.status >= 200 && r.status < 500 });\n",
477 );
478 script.push_str(" }\n");
479
480 let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
482 let encoded = base64_encode(basic_creds);
483 script.push_str(" {\n");
484 script.push_str(&format!(
485 " let res = http.get(`${{BASE_URL}}/conformance/security/basic`, {{ headers: {{ 'Authorization': 'Basic {}' }} }});\n",
486 encoded
487 ));
488 script.push_str(
489 " check(res, { 'security:basic': (r) => r.status >= 200 && r.status < 500 });\n",
490 );
491 script.push_str(" }\n");
492
493 script.push_str(" });\n\n");
494 }
495
496 fn generate_handle_summary(&self, script: &mut String) {
497 script.push_str("export function handleSummary(data) {\n");
498 script.push_str(" // Extract check results for conformance reporting\n");
499 script.push_str(" let checks = {};\n");
500 script.push_str(" if (data.metrics && data.metrics.checks) {\n");
501 script.push_str(" // Overall check pass rate\n");
502 script.push_str(" checks.overall_pass_rate = data.metrics.checks.values.rate;\n");
503 script.push_str(" }\n");
504 script.push_str(" // Collect per-check results from root_group\n");
505 script.push_str(" let checkResults = {};\n");
506 script.push_str(" function walkGroups(group) {\n");
507 script.push_str(" if (group.checks) {\n");
508 script.push_str(" for (let checkObj of group.checks) {\n");
509 script.push_str(" checkResults[checkObj.name] = {\n");
510 script.push_str(" passes: checkObj.passes,\n");
511 script.push_str(" fails: checkObj.fails,\n");
512 script.push_str(" };\n");
513 script.push_str(" }\n");
514 script.push_str(" }\n");
515 script.push_str(" if (group.groups) {\n");
516 script.push_str(" for (let subGroup of group.groups) {\n");
517 script.push_str(" walkGroups(subGroup);\n");
518 script.push_str(" }\n");
519 script.push_str(" }\n");
520 script.push_str(" }\n");
521 script.push_str(" if (data.root_group) {\n");
522 script.push_str(" walkGroups(data.root_group);\n");
523 script.push_str(" }\n");
524 script.push_str(" return {\n");
525 script.push_str(" 'conformance-report.json': JSON.stringify({ checks: checkResults, overall: checks }, null, 2),\n");
526 script.push_str(" stdout: textSummary(data, { indent: ' ', enableColors: true }),\n");
527 script.push_str(" };\n");
528 script.push_str("}\n\n");
529 script.push_str("// textSummary fallback\n");
530 script.push_str("function textSummary(data, opts) {\n");
531 script.push_str(" return JSON.stringify(data, null, 2);\n");
532 script.push_str("}\n");
533 }
534}
535
536fn base64_encode(input: &str) -> String {
538 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
539 let bytes = input.as_bytes();
540 let mut result = String::with_capacity((bytes.len() + 2) / 3 * 4);
541 for chunk in bytes.chunks(3) {
542 let b0 = chunk[0] as u32;
543 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
544 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
545 let triple = (b0 << 16) | (b1 << 8) | b2;
546 result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
547 result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
548 if chunk.len() > 1 {
549 result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
550 } else {
551 result.push('=');
552 }
553 if chunk.len() > 2 {
554 result.push(CHARS[(triple & 0x3F) as usize] as char);
555 } else {
556 result.push('=');
557 }
558 }
559 result
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 #[test]
567 fn test_generate_conformance_script() {
568 let config = ConformanceConfig {
569 target_url: "http://localhost:8080".to_string(),
570 api_key: None,
571 basic_auth: None,
572 skip_tls_verify: false,
573 };
574 let generator = ConformanceGenerator::new(config);
575 let script = generator.generate().unwrap();
576
577 assert!(script.contains("import http from 'k6/http'"));
578 assert!(script.contains("vus: 1"));
579 assert!(script.contains("iterations: 1"));
580 assert!(script.contains("group('Parameters'"));
581 assert!(script.contains("group('Request Bodies'"));
582 assert!(script.contains("group('Schema Types'"));
583 assert!(script.contains("group('Composition'"));
584 assert!(script.contains("group('String Formats'"));
585 assert!(script.contains("group('Constraints'"));
586 assert!(script.contains("group('Response Codes'"));
587 assert!(script.contains("group('HTTP Methods'"));
588 assert!(script.contains("group('Content Types'"));
589 assert!(script.contains("group('Security'"));
590 assert!(script.contains("handleSummary"));
591 }
592
593 #[test]
594 fn test_base64_encode() {
595 assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz");
596 assert_eq!(base64_encode("a"), "YQ==");
597 assert_eq!(base64_encode("ab"), "YWI=");
598 assert_eq!(base64_encode("abc"), "YWJj");
599 }
600
601 #[test]
602 fn test_conformance_script_with_custom_auth() {
603 let config = ConformanceConfig {
604 target_url: "https://api.example.com".to_string(),
605 api_key: Some("my-api-key".to_string()),
606 basic_auth: Some("admin:secret".to_string()),
607 skip_tls_verify: true,
608 };
609 let generator = ConformanceGenerator::new(config);
610 let script = generator.generate().unwrap();
611
612 assert!(script.contains("insecureSkipTLSVerify: true"));
613 assert!(script.contains("my-api-key"));
614 assert!(script.contains(&base64_encode("admin:secret")));
615 }
616}