Skip to main content

poolster_core/
httpmock.rs

1//! Portable [`httpmock`](https://httpmock.rs/) fixture generation.
2//!
3//! The generated YAML is intentionally static: it can be loaded by
4//! httpmock's standalone server, Docker image, or a local Rust test without
5//! bringing a JavaScript runtime or a Poolster runtime into the consumer's test
6//! environment. Dynamic, stateful behaviour belongs in a future Poolster-owned
7//! mock server; static fixtures are the dependable baseline every SDK can
8//! share.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::fmt::Write;
12
13use anyhow::Result;
14use serde_json::{Map, Number, Value};
15
16use crate::{
17    Api, GeneratedFile, MockResponse, MockScenario, Operation, OperationMediaType,
18    OperationResponse, SchemaKind, SchemaValue, extract_mock_scenarios,
19};
20
21/// Options for [`generate_httpmock_fixtures`].
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct HttpmockFixtureConfig {
24    /// Relative directory containing the YAML fixture documents.
25    pub output_dir: String,
26    /// Emit a small standalone-server guide next to the fixtures.
27    pub include_readme: bool,
28}
29
30impl Default for HttpmockFixtureConfig {
31    fn default() -> Self {
32        Self {
33            output_dir: "mocks/httpmock".into(),
34            include_readme: true,
35        }
36    }
37}
38
39/// Generates one static httpmock YAML document for every operation.
40///
41/// Every document has a deterministic happy-path mock. The selected response
42/// is the lowest numeric 2xx response (then `default`, then the first declared
43/// response). Bodies prefer schema `default`, `const`, enum, and examples;
44/// otherwise Poolster creates a conservative schema-shaped value. OpenAPI itself
45/// is deliberately enough for a useful local server: `x-poolster-mock` is only
46/// required for named non-happy-path scenarios.
47///
48/// The resulting files are language neutral. Any generated SDK can set its
49/// base URL to an httpmock standalone server loading these YAML files.
50pub fn generate_httpmock_fixtures(
51    api: &Api,
52    config: &HttpmockFixtureConfig,
53) -> Result<Vec<GeneratedFile>> {
54    let output_dir = config.output_dir.trim_matches('/');
55    let scenarios = extract_mock_scenarios(api)?;
56    let mut files = Vec::with_capacity(api.operations.len() + usize::from(config.include_readme));
57    for operation in &api.operations {
58        let name = fixture_name(&operation.id);
59        let operation_scenarios = scenarios
60            .iter()
61            .filter(|scenario| scenario.operation_id == operation.id)
62            .collect::<Vec<_>>();
63        files.push(GeneratedFile::new(
64            output_path(output_dir, &format!("{name}.yaml")),
65            render_operation_fixture(api, operation, &operation_scenarios),
66        )?);
67    }
68    if config.include_readme {
69        files.push(GeneratedFile::new(
70            output_path(output_dir, "README.md"),
71            render_readme(api),
72        )?);
73    }
74    Ok(files)
75}
76
77fn output_path(output_dir: &str, file: &str) -> String {
78    if output_dir.is_empty() {
79        file.into()
80    } else {
81        format!("{output_dir}/{file}")
82    }
83}
84
85fn render_operation_fixture(
86    api: &Api,
87    operation: &Operation,
88    scenarios: &[&MockScenario],
89) -> String {
90    let mut out = format!(
91        "# Generated by Poolster. Source operation: {}\n",
92        operation.id
93    );
94    // httpmock evaluates matching fixtures in definition order. Emit specific
95    // scenarios first, otherwise the unconstrained happy-path document would
96    // swallow every request before a scenario could match it.
97    for (index, scenario) in scenarios.iter().enumerate() {
98        if index > 0 {
99            out.push_str("---\n");
100        }
101        let _ = writeln!(out, "# Scenario: {}", scenario.name);
102        render_when(&mut out, operation, Some(scenario));
103        out.push_str("then:\n");
104        render_scenario_response(&mut out, &scenario.response);
105    }
106    if !scenarios.is_empty() {
107        out.push_str("---\n");
108    }
109    out.push_str("# Default happy-path response\n");
110    render_when(&mut out, operation, None);
111    out.push_str("then:\n");
112    let response = preferred_response(operation);
113    let status = response.map_or(200, |response| status_code(&response.status));
114    let _ = writeln!(out, "  status: {status}");
115    if let Some(response) = response {
116        if let Some(media_type) = response.media_types.first() {
117            render_response_body(api, media_type, &mut out);
118        }
119    }
120    out
121}
122
123fn render_when(out: &mut String, operation: &Operation, scenario: Option<&MockScenario>) {
124    let _ = writeln!(out, "when:");
125    let _ = writeln!(out, "  method: {}", operation.method.as_str());
126    let values = scenario.map(|scenario| &scenario.when.path);
127    if has_unbound_path_parameter(&operation.path, values) {
128        let _ = writeln!(
129            out,
130            "  path_matches: {}",
131            yaml_scalar(&path_pattern(&operation.path, values)),
132        );
133    } else {
134        let _ = writeln!(
135            out,
136            "  path: {}",
137            yaml_scalar(&path_matcher(&operation.path, values)),
138        );
139    }
140    let Some(scenario) = scenario else {
141        return;
142    };
143    render_match_pairs(out, "header", &scenario.when.headers);
144    render_match_pairs(out, "query_param", &scenario.when.query);
145    if let Some(body) = &scenario.when.body {
146        let json = serde_json::to_string(body).expect("scenario request JSON must serialize");
147        let _ = writeln!(out, "  json_body: {}", yaml_scalar(&json));
148    }
149}
150
151fn render_match_pairs(out: &mut String, key: &str, pairs: &BTreeMap<String, String>) {
152    if pairs.is_empty() {
153        return;
154    }
155    let _ = writeln!(out, "  {key}:");
156    for (name, value) in pairs {
157        let _ = writeln!(out, "    - name: {}", yaml_scalar(name));
158        let _ = writeln!(out, "      value: {}", yaml_scalar(value));
159    }
160}
161
162fn render_scenario_response(out: &mut String, response: &MockResponse) {
163    let _ = writeln!(out, "  status: {}", response.status);
164    render_response_headers(out, &response.headers);
165    if let Some(delay_ms) = response.delay_ms {
166        let _ = writeln!(out, "  delay: {delay_ms}ms");
167    }
168    let Some(body) = &response.body else {
169        return;
170    };
171    let json_content = response
172        .headers
173        .iter()
174        .find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
175        .is_some_and(|(_, value)| {
176            let value = value.to_ascii_lowercase();
177            value.contains("json") || value.ends_with("+json")
178        });
179    if json_content {
180        let json = serde_json::to_string(body).expect("scenario response JSON must serialize");
181        let _ = writeln!(out, "  json_body: {}", yaml_scalar(&json));
182    } else if let Some(text) = body.as_str() {
183        let _ = writeln!(out, "  body: {}", yaml_scalar(text));
184    } else {
185        let json = serde_json::to_string(body).expect("scenario response JSON must serialize");
186        let _ = writeln!(out, "  body: {}", yaml_scalar(&json));
187    }
188}
189
190fn render_response_headers(out: &mut String, headers: &BTreeMap<String, String>) {
191    if headers.is_empty() {
192        return;
193    }
194    let _ = writeln!(out, "  header:");
195    for (name, value) in headers {
196        let _ = writeln!(out, "    - name: {}", yaml_scalar(name));
197        let _ = writeln!(out, "      value: {}", yaml_scalar(value));
198    }
199}
200
201fn preferred_response(operation: &Operation) -> Option<&OperationResponse> {
202    operation
203        .responses
204        .iter()
205        .filter_map(|response| {
206            response
207                .status
208                .parse::<u16>()
209                .ok()
210                .map(|status| (status, response))
211        })
212        .filter(|(status, _)| (200..300).contains(status))
213        .min_by_key(|(status, _)| *status)
214        .map(|(_, response)| response)
215        .or_else(|| {
216            operation
217                .responses
218                .iter()
219                .find(|response| response.status == "default")
220        })
221        .or_else(|| operation.responses.first())
222}
223
224/// Returns the deterministic happy-path response used by Poolster's fixture and
225/// native mock servers. This keeps Docker-based fixtures and `poolster mock serve`
226/// contract-compatible without requiring an external runtime.
227pub fn mock_happy_response(api: &Api, operation: &Operation) -> (u16, Option<String>, Value) {
228    let Some(response) = preferred_response(operation) else {
229        return (200, Some("application/json".into()), Value::Null);
230    };
231    let status = status_code(&response.status);
232    let Some(media_type) = response.media_types.first() else {
233        return (status, None, Value::Null);
234    };
235    let body = media_type
236        .schema
237        .as_ref()
238        .map(|schema| sample_schema(api, schema, &mut BTreeSet::new()))
239        .unwrap_or(Value::Null);
240    (status, Some(media_type.content_type.clone()), body)
241}
242
243/// Returns a happy-path response with a fresh, schema-shaped fallback value.
244/// Explicit OpenAPI examples, defaults, constants, and enum values remain
245/// authoritative; only unconstrained fields vary with `seed`.
246pub fn mock_dynamic_response(
247    api: &Api,
248    operation: &Operation,
249    seed: u64,
250) -> (u16, Option<String>, Value) {
251    let Some(response) = preferred_response(operation) else {
252        return (200, Some("application/json".into()), Value::Null);
253    };
254    let status = status_code(&response.status);
255    let Some(media_type) = response.media_types.first() else {
256        return (status, None, Value::Null);
257    };
258    let body = media_type
259        .schema
260        .as_ref()
261        .map(|schema| dynamic_schema(api, schema, seed, &mut BTreeSet::new()))
262        .unwrap_or(Value::Null);
263    (status, Some(media_type.content_type.clone()), body)
264}
265
266fn status_code(status: &str) -> u16 {
267    status
268        .parse()
269        .ok()
270        .filter(|status| (100..=599).contains(status))
271        .unwrap_or(200)
272}
273
274fn render_response_body(api: &Api, media_type: &OperationMediaType, out: &mut String) {
275    let content_type = media_type.content_type.to_ascii_lowercase();
276    if !content_type.is_empty() {
277        render_response_headers(
278            out,
279            &BTreeMap::from([("content-type".into(), media_type.content_type.clone())]),
280        );
281    }
282    let Some(schema) = &media_type.schema else {
283        return;
284    };
285    let body = sample_schema(api, schema, &mut BTreeSet::new());
286    if content_type.contains("json") || content_type.ends_with("+json") {
287        let json = serde_json::to_string(&body).expect("sample JSON must serialize");
288        let _ = writeln!(out, "  json_body: {}", yaml_scalar(&json));
289    } else if let Some(text) = body.as_str() {
290        let _ = writeln!(out, "  body: {}", yaml_scalar(text));
291    } else {
292        let json = serde_json::to_string(&body).expect("sample JSON must serialize");
293        let _ = writeln!(out, "  body: {}", yaml_scalar(&json));
294    }
295}
296
297fn path_matcher(path: &str, values: Option<&BTreeMap<String, String>>) -> String {
298    // httpmock uses exact paths. Leaving OpenAPI placeholders in place would
299    // never match a real SDK request, so replace each segment with an obvious,
300    // deterministic test identifier. A future dynamic mock runtime can make
301    // path parameters permissive without compromising static fixture safety.
302    path.split('/')
303        .map(|segment| {
304            if segment.starts_with('{') && segment.ends_with('}') {
305                let name = &segment[1..segment.len() - 1];
306                values
307                    .and_then(|values| values.get(name))
308                    .cloned()
309                    .unwrap_or_else(|| "poolster".into())
310            } else {
311                segment.to_owned()
312            }
313        })
314        .collect::<Vec<_>>()
315        .join("/")
316}
317
318fn has_unbound_path_parameter(path: &str, values: Option<&BTreeMap<String, String>>) -> bool {
319    path.split('/').any(|segment| {
320        segment.starts_with('{')
321            && segment.ends_with('}')
322            && values.is_none_or(|values| !values.contains_key(&segment[1..segment.len() - 1]))
323    })
324}
325
326fn path_pattern(path: &str, values: Option<&BTreeMap<String, String>>) -> String {
327    let mut pattern = String::from("^");
328    for segment in path.split_inclusive('/') {
329        // Splitting inclusive retains literal slash boundaries and makes the
330        // generated regular expression faithful to the OpenAPI route.
331        let bare = segment.strip_suffix('/').unwrap_or(segment);
332        if bare.starts_with('{') && bare.ends_with('}') {
333            let name = &bare[1..bare.len() - 1];
334            if let Some(value) = values.and_then(|values| values.get(name)) {
335                push_regex_escaped(&mut pattern, value);
336            } else {
337                pattern.push_str("[^/]+");
338            }
339        } else {
340            push_regex_escaped(&mut pattern, bare);
341        }
342        if segment.ends_with('/') {
343            pattern.push('/');
344        }
345    }
346    pattern.push('$');
347    pattern
348}
349
350fn push_regex_escaped(output: &mut String, value: &str) {
351    for character in value.chars() {
352        if matches!(
353            character,
354            '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '^' | '$' | '{' | '}'
355        ) {
356            output.push('\\');
357        }
358        output.push(character);
359    }
360}
361
362fn sample_schema(api: &Api, schema: &SchemaValue, visiting: &mut BTreeSet<String>) -> Value {
363    if let Some(value) = schema
364        .const_value
365        .clone()
366        .or_else(|| schema.default.clone())
367    {
368        return value;
369    }
370    if let Some(value) = schema.enum_values.first() {
371        return value.clone();
372    }
373    if let Some(example) = schema.constraints.get("example") {
374        return example.clone();
375    }
376    if let Some(Value::Array(examples)) = schema.constraints.get("examples") {
377        if let Some(example) = examples.first() {
378            return example.clone();
379        }
380    }
381    match &schema.kind {
382        SchemaKind::Any => Value::Null,
383        SchemaKind::Null => Value::Null,
384        SchemaKind::Boolean => Value::Bool(false),
385        SchemaKind::Integer => Value::Number(Number::from(0)),
386        SchemaKind::Number => Number::from_f64(0.0).map_or(Value::Null, Value::Number),
387        SchemaKind::String => Value::String(sample_string(schema.format.as_deref())),
388        SchemaKind::Array { items } => Value::Array(vec![sample_schema(api, items, visiting)]),
389        SchemaKind::Object { fields, .. } => Value::Object(
390            fields
391                .iter()
392                .map(|field| {
393                    (
394                        field.name.clone(),
395                        sample_schema(api, &field.value, visiting),
396                    )
397                })
398                .collect::<Map<_, _>>(),
399        ),
400        SchemaKind::Reference { reference } => {
401            let name = reference.rsplit('/').next().unwrap_or(reference);
402            if !visiting.insert(name.into()) {
403                return Value::Null;
404            }
405            let sampled = api
406                .schemas
407                .iter()
408                .find(|schema| schema.name == name)
409                .map(|schema| sample_schema(api, &schema.value, visiting))
410                .unwrap_or(Value::Null);
411            visiting.remove(name);
412            sampled
413        }
414        SchemaKind::OneOf { variants } | SchemaKind::AnyOf { variants } => variants
415            .first()
416            .map(|variant| sample_schema(api, variant, visiting))
417            .unwrap_or(Value::Null),
418        SchemaKind::AllOf { variants } => {
419            let mut result = Map::new();
420            for variant in variants {
421                if let Value::Object(object) = sample_schema(api, variant, visiting) {
422                    result.extend(object);
423                }
424            }
425            Value::Object(result)
426        }
427        SchemaKind::Not { .. } => Value::Null,
428    }
429}
430
431fn dynamic_schema(
432    api: &Api,
433    schema: &SchemaValue,
434    seed: u64,
435    visiting: &mut BTreeSet<String>,
436) -> Value {
437    if let Some(value) = schema
438        .const_value
439        .clone()
440        .or_else(|| schema.default.clone())
441    {
442        return value;
443    }
444    if let Some(value) = schema.enum_values.first() {
445        return value.clone();
446    }
447    if let Some(example) = schema.constraints.get("example") {
448        return example.clone();
449    }
450    if let Some(Value::Array(examples)) = schema.constraints.get("examples") {
451        if let Some(example) = examples.first() {
452            return example.clone();
453        }
454    }
455    match &schema.kind {
456        SchemaKind::Any | SchemaKind::Null | SchemaKind::Not { .. } => Value::Null,
457        SchemaKind::Boolean => Value::Bool(seed % 2 == 0),
458        SchemaKind::Integer => Value::Number(Number::from(seed)),
459        SchemaKind::Number => {
460            Number::from_f64(seed as f64 + 0.5).map_or(Value::Null, Value::Number)
461        }
462        SchemaKind::String => Value::String(dynamic_string(schema.format.as_deref(), seed)),
463        SchemaKind::Array { items } => Value::Array(vec![dynamic_schema(
464            api,
465            items,
466            seed.saturating_add(1),
467            visiting,
468        )]),
469        SchemaKind::Object { fields, .. } => Value::Object(
470            fields
471                .iter()
472                .enumerate()
473                .map(|(index, field)| {
474                    (
475                        field.name.clone(),
476                        dynamic_schema(
477                            api,
478                            &field.value,
479                            seed.saturating_add(index as u64),
480                            visiting,
481                        ),
482                    )
483                })
484                .collect::<Map<_, _>>(),
485        ),
486        SchemaKind::Reference { reference } => {
487            let name = reference.rsplit('/').next().unwrap_or(reference);
488            if !visiting.insert(name.into()) {
489                return Value::Null;
490            }
491            let sampled = api
492                .schemas
493                .iter()
494                .find(|schema| schema.name == name)
495                .map(|schema| dynamic_schema(api, &schema.value, seed, visiting))
496                .unwrap_or(Value::Null);
497            visiting.remove(name);
498            sampled
499        }
500        SchemaKind::OneOf { variants } | SchemaKind::AnyOf { variants } => variants
501            .first()
502            .map(|variant| dynamic_schema(api, variant, seed, visiting))
503            .unwrap_or(Value::Null),
504        SchemaKind::AllOf { variants } => {
505            let mut result = Map::new();
506            for variant in variants {
507                if let Value::Object(object) = dynamic_schema(api, variant, seed, visiting) {
508                    result.extend(object);
509                }
510            }
511            Value::Object(result)
512        }
513    }
514}
515
516fn dynamic_string(format: Option<&str>, seed: u64) -> String {
517    match format.unwrap_or_default() {
518        "email" => format!("mock-{seed}@example.test"),
519        "uuid" => format!("00000000-0000-4000-8000-{seed:012x}"),
520        "date" => format!("2026-01-{:02}", seed % 28 + 1),
521        "date-time" | "datetime" => format!("2026-01-{:02}T12:00:00Z", seed % 28 + 1),
522        "uri" | "url" => format!("https://mock.example.test/resources/{seed}"),
523        _ => format!("mock-{seed}"),
524    }
525}
526
527fn sample_string(format: Option<&str>) -> String {
528    match format.unwrap_or_default() {
529        "email" => "mock@example.com".into(),
530        "uuid" => "00000000-0000-4000-8000-000000000000".into(),
531        "date" => "2020-01-01".into(),
532        "date-time" | "datetime" => "2020-01-01T00:00:00Z".into(),
533        "uri" | "url" => "https://example.com/mock".into(),
534        "binary" | "byte" => "mock".into(),
535        _ => "mock".into(),
536    }
537}
538
539fn fixture_name(operation_id: &str) -> String {
540    let mut name = String::with_capacity(operation_id.len());
541    let mut previous_dash = false;
542    for character in operation_id.chars() {
543        if character.is_ascii_alphanumeric() {
544            name.push(character.to_ascii_lowercase());
545            previous_dash = false;
546        } else if !previous_dash {
547            name.push('-');
548            previous_dash = true;
549        }
550    }
551    let name = name.trim_matches('-');
552    if name.is_empty() {
553        "operation".into()
554    } else {
555        name.into()
556    }
557}
558
559fn yaml_scalar(value: &str) -> String {
560    // Single quotes are the least surprising YAML representation for JSON,
561    // paths, headers, and user-provided strings. YAML escapes a quote by
562    // doubling it, so this remains lossless without needing a YAML dependency.
563    format!("'{}'", value.replace('\'', "''"))
564}
565
566fn render_readme(api: &Api) -> String {
567    format!(
568        "# {} httpmock fixtures\n\nGenerated by Poolster from {} operation(s). Run a standalone httpmock server with:\n\n```sh\ndocker run --rm -p 5000:5000 -e HTTPMOCK_MOCK_FILES_DIR=/mocks -v \"$PWD:/mocks:ro\" httpmock/httpmock\n```\n\nThen configure any generated SDK with `baseUrl: \"http://localhost:5000\"`. Each YAML document is an independent static mock. Its happy-path response is derived from the declared OpenAPI response schema.\n\nTo add named errors or other cases, use `x-poolster-mock` on the operation; Poolster emits them as additional YAML documents in the operation fixture. A scenario can match exact headers, query values, path parameters, and a JSON body.\n",
569        api.name,
570        api.operations.len()
571    )
572}
573
574#[cfg(test)]
575mod tests {
576    use std::collections::BTreeMap;
577
578    use serde_json::json;
579
580    use super::*;
581    use crate::{Field, HttpMethod, OperationResponse, Schema};
582
583    #[test]
584    fn emits_static_happy_path_fixtures_with_schema_defaults() {
585        let api = Api {
586            name: "Poolster Email".into(),
587            version: "1".into(),
588            schemas: vec![Schema::new(
589                "Contact",
590                SchemaValue::new(SchemaKind::Object {
591                    fields: vec![
592                        Field {
593                            name: "id".into(),
594                            value: SchemaValue {
595                                default: Some(json!("contact_123")),
596                                ..SchemaValue::new(SchemaKind::String)
597                            },
598                            required: true,
599                            annotations: BTreeMap::new(),
600                        },
601                        Field {
602                            name: "email".into(),
603                            value: SchemaValue {
604                                format: Some("email".into()),
605                                ..SchemaValue::new(SchemaKind::String)
606                            },
607                            required: true,
608                            annotations: BTreeMap::new(),
609                        },
610                    ],
611                    additional_properties: Default::default(),
612                }),
613            )],
614            operations: vec![Operation {
615                id: "getContact".into(),
616                method: HttpMethod::Get,
617                path: "/v1/contacts/{contact_id}".into(),
618                parameters: vec![],
619                request_body: None,
620                responses: vec![OperationResponse {
621                    status: "200".into(),
622                    description: None,
623                    media_types: vec![OperationMediaType {
624                        content_type: "application/json".into(),
625                        schema: Some(SchemaValue::reference("#/components/schemas/Contact")),
626                    }],
627                }],
628                security: vec![],
629                annotations: BTreeMap::from([(
630                    "x-poolster-mock".into(),
631                    json!({
632                        "scenarios": [{
633                            "name": "rate-limited",
634                            "when": {
635                                "path": { "contact_id": "contact_429" },
636                                "headers": { "x-test-scenario": "rate-limited" },
637                                "query": { "expand": "history" },
638                                "body": { "ignored": false }
639                            },
640                            "response": {
641                                "status": 429,
642                                "headers": { "content-type": "application/json", "retry-after": "1" },
643                                "body": { "message": "Too many requests" },
644                                "delay_ms": 10
645                            }
646                        }]
647                    }),
648                )]),
649            }],
650            annotations: BTreeMap::new(),
651        };
652
653        let files = generate_httpmock_fixtures(&api, &HttpmockFixtureConfig::default()).unwrap();
654        let fixture = files
655            .iter()
656            .find(|file| file.path == std::path::Path::new("mocks/httpmock/getcontact.yaml"))
657            .unwrap();
658        assert!(fixture.contents.contains("method: GET"));
659        assert!(
660            fixture
661                .contents
662                .contains("path_matches: '^/v1/contacts/[^/]+$'")
663        );
664        assert!(fixture.contents.contains("status: 200"));
665        assert!(
666            fixture
667                .contents
668                .contains("json_body: '{\"email\":\"mock@example.com\",\"id\":\"contact_123\"}'")
669        );
670        assert!(fixture.contents.contains("# Scenario: rate-limited"));
671        assert!(
672            fixture.contents.find("# Scenario: rate-limited").unwrap()
673                < fixture
674                    .contents
675                    .find("# Default happy-path response")
676                    .unwrap()
677        );
678        assert!(
679            fixture
680                .contents
681                .contains("path: '/v1/contacts/contact_429'")
682        );
683        assert!(fixture.contents.contains("query_param:"));
684        assert!(fixture.contents.contains("status: 429"));
685        assert!(fixture.contents.contains("delay: 10ms"));
686        assert!(
687            fixture
688                .contents
689                .contains("json_body: '{\"message\":\"Too many requests\"}'")
690        );
691    }
692
693    #[test]
694    fn selects_lowest_successful_response_and_sanitizes_fixture_names() {
695        let operation = Operation {
696            id: "Create contact!".into(),
697            method: HttpMethod::Post,
698            path: "/contacts".into(),
699            parameters: vec![],
700            request_body: None,
701            responses: vec![
702                OperationResponse {
703                    status: "404".into(),
704                    description: None,
705                    media_types: vec![],
706                },
707                OperationResponse {
708                    status: "201".into(),
709                    description: None,
710                    media_types: vec![],
711                },
712                OperationResponse {
713                    status: "200".into(),
714                    description: None,
715                    media_types: vec![],
716                },
717            ],
718            security: vec![],
719            annotations: BTreeMap::new(),
720        };
721        let files = generate_httpmock_fixtures(
722            &Api {
723                name: "Test".into(),
724                version: "1".into(),
725                schemas: vec![],
726                operations: vec![operation],
727                annotations: BTreeMap::new(),
728            },
729            &HttpmockFixtureConfig {
730                output_dir: "".into(),
731                include_readme: false,
732            },
733        )
734        .unwrap();
735        assert_eq!(files[0].path, std::path::Path::new("create-contact.yaml"));
736        assert!(files[0].contents.contains("status: 200"));
737    }
738
739    #[test]
740    fn dynamic_responses_vary_unconstrained_schema_values() {
741        let api = Api {
742            name: "Test".into(),
743            version: "1".into(),
744            schemas: vec![],
745            operations: vec![],
746            annotations: BTreeMap::new(),
747        };
748        let operation = Operation {
749            id: "getWidget".into(),
750            method: HttpMethod::Get,
751            path: "/widgets/{id}".into(),
752            parameters: vec![],
753            request_body: None,
754            responses: vec![OperationResponse {
755                status: "200".into(),
756                description: None,
757                media_types: vec![OperationMediaType {
758                    content_type: "application/json".into(),
759                    schema: Some(SchemaValue::new(SchemaKind::Object {
760                        fields: vec![Field {
761                            name: "email".into(),
762                            value: SchemaValue {
763                                format: Some("email".into()),
764                                ..SchemaValue::new(SchemaKind::String)
765                            },
766                            required: true,
767                            annotations: BTreeMap::new(),
768                        }],
769                        additional_properties: Default::default(),
770                    })),
771                }],
772            }],
773            security: vec![],
774            annotations: BTreeMap::new(),
775        };
776        let (_, _, first) = mock_dynamic_response(&api, &operation, 1);
777        let (_, _, second) = mock_dynamic_response(&api, &operation, 2);
778        assert_eq!(first["email"], "mock-1@example.test");
779        assert_eq!(second["email"], "mock-2@example.test");
780    }
781}