//! Portable [`httpmock`](https://httpmock.rs/) fixture generation.
//!
//! The generated YAML is intentionally static: it can be loaded by
//! httpmock's standalone server, Docker image, or a local Rust test without
//! bringing a JavaScript runtime or a Poolster runtime into the consumer's test
//! environment. Dynamic, stateful behaviour belongs in a future Poolster-owned
//! mock server; static fixtures are the dependable baseline every SDK can
//! share.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write;
use anyhow::Result;
use serde_json::{Map, Number, Value};
use crate::{
Api, GeneratedFile, MockResponse, MockScenario, Operation, OperationMediaType,
OperationResponse, SchemaKind, SchemaValue, extract_mock_scenarios,
};
/// Options for [`generate_httpmock_fixtures`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HttpmockFixtureConfig {
/// Relative directory containing the YAML fixture documents.
pub output_dir: String,
/// Emit a small standalone-server guide next to the fixtures.
pub include_readme: bool,
}
impl Default for HttpmockFixtureConfig {
fn default() -> Self {
Self {
output_dir: "mocks/httpmock".into(),
include_readme: true,
}
}
}
/// Generates one static httpmock YAML document for every operation.
///
/// Every document has a deterministic happy-path mock. The selected response
/// is the lowest numeric 2xx response (then `default`, then the first declared
/// response). Bodies prefer schema `default`, `const`, enum, and examples;
/// otherwise Poolster creates a conservative schema-shaped value. OpenAPI itself
/// is deliberately enough for a useful local server: `x-poolster-mock` is only
/// required for named non-happy-path scenarios.
///
/// The resulting files are language neutral. Any generated SDK can set its
/// base URL to an httpmock standalone server loading these YAML files.
pub fn generate_httpmock_fixtures(
api: &Api,
config: &HttpmockFixtureConfig,
) -> Result<Vec<GeneratedFile>> {
let output_dir = config.output_dir.trim_matches('/');
let scenarios = extract_mock_scenarios(api)?;
let mut files = Vec::with_capacity(api.operations.len() + usize::from(config.include_readme));
for operation in &api.operations {
let name = fixture_name(&operation.id);
let operation_scenarios = scenarios
.iter()
.filter(|scenario| scenario.operation_id == operation.id)
.collect::<Vec<_>>();
files.push(GeneratedFile::new(
output_path(output_dir, &format!("{name}.yaml")),
render_operation_fixture(api, operation, &operation_scenarios),
)?);
}
if config.include_readme {
files.push(GeneratedFile::new(
output_path(output_dir, "README.md"),
render_readme(api),
)?);
}
Ok(files)
}
fn output_path(output_dir: &str, file: &str) -> String {
if output_dir.is_empty() {
file.into()
} else {
format!("{output_dir}/{file}")
}
}
fn render_operation_fixture(
api: &Api,
operation: &Operation,
scenarios: &[&MockScenario],
) -> String {
let mut out = format!(
"# Generated by Poolster. Source operation: {}\n",
operation.id
);
// httpmock evaluates matching fixtures in definition order. Emit specific
// scenarios first, otherwise the unconstrained happy-path document would
// swallow every request before a scenario could match it.
for (index, scenario) in scenarios.iter().enumerate() {
if index > 0 {
out.push_str("---\n");
}
let _ = writeln!(out, "# Scenario: {}", scenario.name);
render_when(&mut out, operation, Some(scenario));
out.push_str("then:\n");
render_scenario_response(&mut out, &scenario.response);
}
if !scenarios.is_empty() {
out.push_str("---\n");
}
out.push_str("# Default happy-path response\n");
render_when(&mut out, operation, None);
out.push_str("then:\n");
let response = preferred_response(operation);
let status = response.map_or(200, |response| status_code(&response.status));
let _ = writeln!(out, " status: {status}");
if let Some(response) = response {
if let Some(media_type) = response.media_types.first() {
render_response_body(api, media_type, &mut out);
}
}
out
}
fn render_when(out: &mut String, operation: &Operation, scenario: Option<&MockScenario>) {
let _ = writeln!(out, "when:");
let _ = writeln!(out, " method: {}", operation.method.as_str());
let values = scenario.map(|scenario| &scenario.when.path);
if has_unbound_path_parameter(&operation.path, values) {
let _ = writeln!(
out,
" path_matches: {}",
yaml_scalar(&path_pattern(&operation.path, values)),
);
} else {
let _ = writeln!(
out,
" path: {}",
yaml_scalar(&path_matcher(&operation.path, values)),
);
}
let Some(scenario) = scenario else {
return;
};
render_match_pairs(out, "header", &scenario.when.headers);
render_match_pairs(out, "query_param", &scenario.when.query);
if let Some(body) = &scenario.when.body {
let json = serde_json::to_string(body).expect("scenario request JSON must serialize");
let _ = writeln!(out, " json_body: {}", yaml_scalar(&json));
}
}
fn render_match_pairs(out: &mut String, key: &str, pairs: &BTreeMap<String, String>) {
if pairs.is_empty() {
return;
}
let _ = writeln!(out, " {key}:");
for (name, value) in pairs {
let _ = writeln!(out, " - name: {}", yaml_scalar(name));
let _ = writeln!(out, " value: {}", yaml_scalar(value));
}
}
fn render_scenario_response(out: &mut String, response: &MockResponse) {
let _ = writeln!(out, " status: {}", response.status);
render_response_headers(out, &response.headers);
if let Some(delay_ms) = response.delay_ms {
let _ = writeln!(out, " delay: {delay_ms}ms");
}
let Some(body) = &response.body else {
return;
};
let json_content = response
.headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
.is_some_and(|(_, value)| {
let value = value.to_ascii_lowercase();
value.contains("json") || value.ends_with("+json")
});
if json_content {
let json = serde_json::to_string(body).expect("scenario response JSON must serialize");
let _ = writeln!(out, " json_body: {}", yaml_scalar(&json));
} else if let Some(text) = body.as_str() {
let _ = writeln!(out, " body: {}", yaml_scalar(text));
} else {
let json = serde_json::to_string(body).expect("scenario response JSON must serialize");
let _ = writeln!(out, " body: {}", yaml_scalar(&json));
}
}
fn render_response_headers(out: &mut String, headers: &BTreeMap<String, String>) {
if headers.is_empty() {
return;
}
let _ = writeln!(out, " header:");
for (name, value) in headers {
let _ = writeln!(out, " - name: {}", yaml_scalar(name));
let _ = writeln!(out, " value: {}", yaml_scalar(value));
}
}
fn preferred_response(operation: &Operation) -> Option<&OperationResponse> {
operation
.responses
.iter()
.filter_map(|response| {
response
.status
.parse::<u16>()
.ok()
.map(|status| (status, response))
})
.filter(|(status, _)| (200..300).contains(status))
.min_by_key(|(status, _)| *status)
.map(|(_, response)| response)
.or_else(|| {
operation
.responses
.iter()
.find(|response| response.status == "default")
})
.or_else(|| operation.responses.first())
}
/// Returns the deterministic happy-path response used by Poolster's fixture and
/// native mock servers. This keeps Docker-based fixtures and `poolster mock serve`
/// contract-compatible without requiring an external runtime.
pub fn mock_happy_response(api: &Api, operation: &Operation) -> (u16, Option<String>, Value) {
let Some(response) = preferred_response(operation) else {
return (200, Some("application/json".into()), Value::Null);
};
let status = status_code(&response.status);
let Some(media_type) = response.media_types.first() else {
return (status, None, Value::Null);
};
let body = media_type
.schema
.as_ref()
.map(|schema| sample_schema(api, schema, &mut BTreeSet::new()))
.unwrap_or(Value::Null);
(status, Some(media_type.content_type.clone()), body)
}
/// Returns a happy-path response with a fresh, schema-shaped fallback value.
/// Explicit OpenAPI examples, defaults, constants, and enum values remain
/// authoritative; only unconstrained fields vary with `seed`.
pub fn mock_dynamic_response(
api: &Api,
operation: &Operation,
seed: u64,
) -> (u16, Option<String>, Value) {
let Some(response) = preferred_response(operation) else {
return (200, Some("application/json".into()), Value::Null);
};
let status = status_code(&response.status);
let Some(media_type) = response.media_types.first() else {
return (status, None, Value::Null);
};
let body = media_type
.schema
.as_ref()
.map(|schema| dynamic_schema(api, schema, seed, &mut BTreeSet::new()))
.unwrap_or(Value::Null);
(status, Some(media_type.content_type.clone()), body)
}
fn status_code(status: &str) -> u16 {
status
.parse()
.ok()
.filter(|status| (100..=599).contains(status))
.unwrap_or(200)
}
fn render_response_body(api: &Api, media_type: &OperationMediaType, out: &mut String) {
let content_type = media_type.content_type.to_ascii_lowercase();
if !content_type.is_empty() {
render_response_headers(
out,
&BTreeMap::from([("content-type".into(), media_type.content_type.clone())]),
);
}
let Some(schema) = &media_type.schema else {
return;
};
let body = sample_schema(api, schema, &mut BTreeSet::new());
if content_type.contains("json") || content_type.ends_with("+json") {
let json = serde_json::to_string(&body).expect("sample JSON must serialize");
let _ = writeln!(out, " json_body: {}", yaml_scalar(&json));
} else if let Some(text) = body.as_str() {
let _ = writeln!(out, " body: {}", yaml_scalar(text));
} else {
let json = serde_json::to_string(&body).expect("sample JSON must serialize");
let _ = writeln!(out, " body: {}", yaml_scalar(&json));
}
}
fn path_matcher(path: &str, values: Option<&BTreeMap<String, String>>) -> String {
// httpmock uses exact paths. Leaving OpenAPI placeholders in place would
// never match a real SDK request, so replace each segment with an obvious,
// deterministic test identifier. A future dynamic mock runtime can make
// path parameters permissive without compromising static fixture safety.
path.split('/')
.map(|segment| {
if segment.starts_with('{') && segment.ends_with('}') {
let name = &segment[1..segment.len() - 1];
values
.and_then(|values| values.get(name))
.cloned()
.unwrap_or_else(|| "poolster".into())
} else {
segment.to_owned()
}
})
.collect::<Vec<_>>()
.join("/")
}
fn has_unbound_path_parameter(path: &str, values: Option<&BTreeMap<String, String>>) -> bool {
path.split('/').any(|segment| {
segment.starts_with('{')
&& segment.ends_with('}')
&& values.is_none_or(|values| !values.contains_key(&segment[1..segment.len() - 1]))
})
}
fn path_pattern(path: &str, values: Option<&BTreeMap<String, String>>) -> String {
let mut pattern = String::from("^");
for segment in path.split_inclusive('/') {
// Splitting inclusive retains literal slash boundaries and makes the
// generated regular expression faithful to the OpenAPI route.
let bare = segment.strip_suffix('/').unwrap_or(segment);
if bare.starts_with('{') && bare.ends_with('}') {
let name = &bare[1..bare.len() - 1];
if let Some(value) = values.and_then(|values| values.get(name)) {
push_regex_escaped(&mut pattern, value);
} else {
pattern.push_str("[^/]+");
}
} else {
push_regex_escaped(&mut pattern, bare);
}
if segment.ends_with('/') {
pattern.push('/');
}
}
pattern.push('$');
pattern
}
fn push_regex_escaped(output: &mut String, value: &str) {
for character in value.chars() {
if matches!(
character,
'\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '^' | '$' | '{' | '}'
) {
output.push('\\');
}
output.push(character);
}
}
fn sample_schema(api: &Api, schema: &SchemaValue, visiting: &mut BTreeSet<String>) -> Value {
if let Some(value) = schema
.const_value
.clone()
.or_else(|| schema.default.clone())
{
return value;
}
if let Some(value) = schema.enum_values.first() {
return value.clone();
}
if let Some(example) = schema.constraints.get("example") {
return example.clone();
}
if let Some(Value::Array(examples)) = schema.constraints.get("examples") {
if let Some(example) = examples.first() {
return example.clone();
}
}
match &schema.kind {
SchemaKind::Any => Value::Null,
SchemaKind::Null => Value::Null,
SchemaKind::Boolean => Value::Bool(false),
SchemaKind::Integer => Value::Number(Number::from(0)),
SchemaKind::Number => Number::from_f64(0.0).map_or(Value::Null, Value::Number),
SchemaKind::String => Value::String(sample_string(schema.format.as_deref())),
SchemaKind::Array { items } => Value::Array(vec![sample_schema(api, items, visiting)]),
SchemaKind::Object { fields, .. } => Value::Object(
fields
.iter()
.map(|field| {
(
field.name.clone(),
sample_schema(api, &field.value, visiting),
)
})
.collect::<Map<_, _>>(),
),
SchemaKind::Reference { reference } => {
let name = reference.rsplit('/').next().unwrap_or(reference);
if !visiting.insert(name.into()) {
return Value::Null;
}
let sampled = api
.schemas
.iter()
.find(|schema| schema.name == name)
.map(|schema| sample_schema(api, &schema.value, visiting))
.unwrap_or(Value::Null);
visiting.remove(name);
sampled
}
SchemaKind::OneOf { variants } | SchemaKind::AnyOf { variants } => variants
.first()
.map(|variant| sample_schema(api, variant, visiting))
.unwrap_or(Value::Null),
SchemaKind::AllOf { variants } => {
let mut result = Map::new();
for variant in variants {
if let Value::Object(object) = sample_schema(api, variant, visiting) {
result.extend(object);
}
}
Value::Object(result)
}
SchemaKind::Not { .. } => Value::Null,
}
}
fn dynamic_schema(
api: &Api,
schema: &SchemaValue,
seed: u64,
visiting: &mut BTreeSet<String>,
) -> Value {
if let Some(value) = schema
.const_value
.clone()
.or_else(|| schema.default.clone())
{
return value;
}
if let Some(value) = schema.enum_values.first() {
return value.clone();
}
if let Some(example) = schema.constraints.get("example") {
return example.clone();
}
if let Some(Value::Array(examples)) = schema.constraints.get("examples") {
if let Some(example) = examples.first() {
return example.clone();
}
}
match &schema.kind {
SchemaKind::Any | SchemaKind::Null | SchemaKind::Not { .. } => Value::Null,
SchemaKind::Boolean => Value::Bool(seed % 2 == 0),
SchemaKind::Integer => Value::Number(Number::from(seed)),
SchemaKind::Number => {
Number::from_f64(seed as f64 + 0.5).map_or(Value::Null, Value::Number)
}
SchemaKind::String => Value::String(dynamic_string(schema.format.as_deref(), seed)),
SchemaKind::Array { items } => Value::Array(vec![dynamic_schema(
api,
items,
seed.saturating_add(1),
visiting,
)]),
SchemaKind::Object { fields, .. } => Value::Object(
fields
.iter()
.enumerate()
.map(|(index, field)| {
(
field.name.clone(),
dynamic_schema(
api,
&field.value,
seed.saturating_add(index as u64),
visiting,
),
)
})
.collect::<Map<_, _>>(),
),
SchemaKind::Reference { reference } => {
let name = reference.rsplit('/').next().unwrap_or(reference);
if !visiting.insert(name.into()) {
return Value::Null;
}
let sampled = api
.schemas
.iter()
.find(|schema| schema.name == name)
.map(|schema| dynamic_schema(api, &schema.value, seed, visiting))
.unwrap_or(Value::Null);
visiting.remove(name);
sampled
}
SchemaKind::OneOf { variants } | SchemaKind::AnyOf { variants } => variants
.first()
.map(|variant| dynamic_schema(api, variant, seed, visiting))
.unwrap_or(Value::Null),
SchemaKind::AllOf { variants } => {
let mut result = Map::new();
for variant in variants {
if let Value::Object(object) = dynamic_schema(api, variant, seed, visiting) {
result.extend(object);
}
}
Value::Object(result)
}
}
}
fn dynamic_string(format: Option<&str>, seed: u64) -> String {
match format.unwrap_or_default() {
"email" => format!("mock-{seed}@example.test"),
"uuid" => format!("00000000-0000-4000-8000-{seed:012x}"),
"date" => format!("2026-01-{:02}", seed % 28 + 1),
"date-time" | "datetime" => format!("2026-01-{:02}T12:00:00Z", seed % 28 + 1),
"uri" | "url" => format!("https://mock.example.test/resources/{seed}"),
_ => format!("mock-{seed}"),
}
}
fn sample_string(format: Option<&str>) -> String {
match format.unwrap_or_default() {
"email" => "mock@example.com".into(),
"uuid" => "00000000-0000-4000-8000-000000000000".into(),
"date" => "2020-01-01".into(),
"date-time" | "datetime" => "2020-01-01T00:00:00Z".into(),
"uri" | "url" => "https://example.com/mock".into(),
"binary" | "byte" => "mock".into(),
_ => "mock".into(),
}
}
fn fixture_name(operation_id: &str) -> String {
let mut name = String::with_capacity(operation_id.len());
let mut previous_dash = false;
for character in operation_id.chars() {
if character.is_ascii_alphanumeric() {
name.push(character.to_ascii_lowercase());
previous_dash = false;
} else if !previous_dash {
name.push('-');
previous_dash = true;
}
}
let name = name.trim_matches('-');
if name.is_empty() {
"operation".into()
} else {
name.into()
}
}
fn yaml_scalar(value: &str) -> String {
// Single quotes are the least surprising YAML representation for JSON,
// paths, headers, and user-provided strings. YAML escapes a quote by
// doubling it, so this remains lossless without needing a YAML dependency.
format!("'{}'", value.replace('\'', "''"))
}
fn render_readme(api: &Api) -> String {
format!(
"# {} 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",
api.name,
api.operations.len()
)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use serde_json::json;
use super::*;
use crate::{Field, HttpMethod, OperationResponse, Schema};
#[test]
fn emits_static_happy_path_fixtures_with_schema_defaults() {
let api = Api {
name: "Poolster Email".into(),
version: "1".into(),
schemas: vec![Schema::new(
"Contact",
SchemaValue::new(SchemaKind::Object {
fields: vec![
Field {
name: "id".into(),
value: SchemaValue {
default: Some(json!("contact_123")),
..SchemaValue::new(SchemaKind::String)
},
required: true,
annotations: BTreeMap::new(),
},
Field {
name: "email".into(),
value: SchemaValue {
format: Some("email".into()),
..SchemaValue::new(SchemaKind::String)
},
required: true,
annotations: BTreeMap::new(),
},
],
additional_properties: Default::default(),
}),
)],
operations: vec![Operation {
id: "getContact".into(),
method: HttpMethod::Get,
path: "/v1/contacts/{contact_id}".into(),
parameters: vec![],
request_body: None,
responses: vec![OperationResponse {
status: "200".into(),
description: None,
media_types: vec![OperationMediaType {
content_type: "application/json".into(),
schema: Some(SchemaValue::reference("#/components/schemas/Contact")),
}],
}],
security: vec![],
annotations: BTreeMap::from([(
"x-poolster-mock".into(),
json!({
"scenarios": [{
"name": "rate-limited",
"when": {
"path": { "contact_id": "contact_429" },
"headers": { "x-test-scenario": "rate-limited" },
"query": { "expand": "history" },
"body": { "ignored": false }
},
"response": {
"status": 429,
"headers": { "content-type": "application/json", "retry-after": "1" },
"body": { "message": "Too many requests" },
"delay_ms": 10
}
}]
}),
)]),
}],
annotations: BTreeMap::new(),
};
let files = generate_httpmock_fixtures(&api, &HttpmockFixtureConfig::default()).unwrap();
let fixture = files
.iter()
.find(|file| file.path == std::path::Path::new("mocks/httpmock/getcontact.yaml"))
.unwrap();
assert!(fixture.contents.contains("method: GET"));
assert!(
fixture
.contents
.contains("path_matches: '^/v1/contacts/[^/]+$'")
);
assert!(fixture.contents.contains("status: 200"));
assert!(
fixture
.contents
.contains("json_body: '{\"email\":\"mock@example.com\",\"id\":\"contact_123\"}'")
);
assert!(fixture.contents.contains("# Scenario: rate-limited"));
assert!(
fixture.contents.find("# Scenario: rate-limited").unwrap()
< fixture
.contents
.find("# Default happy-path response")
.unwrap()
);
assert!(
fixture
.contents
.contains("path: '/v1/contacts/contact_429'")
);
assert!(fixture.contents.contains("query_param:"));
assert!(fixture.contents.contains("status: 429"));
assert!(fixture.contents.contains("delay: 10ms"));
assert!(
fixture
.contents
.contains("json_body: '{\"message\":\"Too many requests\"}'")
);
}
#[test]
fn selects_lowest_successful_response_and_sanitizes_fixture_names() {
let operation = Operation {
id: "Create contact!".into(),
method: HttpMethod::Post,
path: "/contacts".into(),
parameters: vec![],
request_body: None,
responses: vec![
OperationResponse {
status: "404".into(),
description: None,
media_types: vec![],
},
OperationResponse {
status: "201".into(),
description: None,
media_types: vec![],
},
OperationResponse {
status: "200".into(),
description: None,
media_types: vec![],
},
],
security: vec![],
annotations: BTreeMap::new(),
};
let files = generate_httpmock_fixtures(
&Api {
name: "Test".into(),
version: "1".into(),
schemas: vec![],
operations: vec![operation],
annotations: BTreeMap::new(),
},
&HttpmockFixtureConfig {
output_dir: "".into(),
include_readme: false,
},
)
.unwrap();
assert_eq!(files[0].path, std::path::Path::new("create-contact.yaml"));
assert!(files[0].contents.contains("status: 200"));
}
#[test]
fn dynamic_responses_vary_unconstrained_schema_values() {
let api = Api {
name: "Test".into(),
version: "1".into(),
schemas: vec![],
operations: vec![],
annotations: BTreeMap::new(),
};
let operation = Operation {
id: "getWidget".into(),
method: HttpMethod::Get,
path: "/widgets/{id}".into(),
parameters: vec![],
request_body: None,
responses: vec![OperationResponse {
status: "200".into(),
description: None,
media_types: vec![OperationMediaType {
content_type: "application/json".into(),
schema: Some(SchemaValue::new(SchemaKind::Object {
fields: vec![Field {
name: "email".into(),
value: SchemaValue {
format: Some("email".into()),
..SchemaValue::new(SchemaKind::String)
},
required: true,
annotations: BTreeMap::new(),
}],
additional_properties: Default::default(),
})),
}],
}],
security: vec![],
annotations: BTreeMap::new(),
};
let (_, _, first) = mock_dynamic_response(&api, &operation, 1);
let (_, _, second) = mock_dynamic_response(&api, &operation, 2);
assert_eq!(first["email"], "mock-1@example.test");
assert_eq!(second["email"], "mock-2@example.test");
}
}