use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Map, Number, Value};
use crate::contract::{
ContractDiscriminatorBranch, ContractDocument, ContractOperation, ContractParameter,
ContractParameterShape, ContractResponseSchema, ContractScalarDefinition,
ContractSchemaDefinition, ContractSchemaField, ContractSchemaShape,
ContractSecurityRequirement, ContractSecuritySchemeKind, ContractTypeRef,
};
use super::{OpenApiCapabilityDiagnostic, OpenApiImportPlan, OpenApiMaterialization, OpenApiSupportLevel};
pub fn materialize_import(plan: &OpenApiImportPlan) -> OpenApiMaterialization {
let mut diagnostics = Vec::new();
let mut sections = Vec::new();
let mut imported_operations = Vec::new();
let mut component_scalar_seeds = BTreeSet::new();
let mut component_schema_seeds = BTreeSet::new();
let mut synthetic_scalars = Vec::new();
let mut synthetic_schemas = Vec::new();
let mut oauth_profiles = Vec::new();
let mut oauth_profile_names = BTreeMap::new();
let mut used_names = known_definition_names(&plan.contract);
let definition_index = DefinitionIndex::new(&plan.contract);
for &index in &plan.selected_operations {
let Some(operation) = plan.contract.operations.get(index) else {
continue;
};
if matches!(operation.method.as_str(), "HEAD" | "TRACE") {
diagnostics.push(skip_warning(
"openapi_unsupported_method",
format!(
"Skipping {} {} because Hen does not materialize {} requests yet.",
operation.method, operation.path, operation.method
),
));
continue;
}
if let Some(parameter) = operation
.parameters
.iter()
.find(|parameter| parameter.required && !parameter_can_be_materialized(parameter))
{
diagnostics.push(skip_warning(
"openapi_required_parameter_unimplemented",
format!(
"Skipping {} {} because required {} parameter '{}' uses unsupported OpenAPI serialization (style={}, explode={}, allowReserved={}, shape={}).",
operation.method,
operation.path,
parameter.location,
parameter.name,
parameter.style,
parameter.explode,
parameter.allow_reserved,
parameter_shape_name(¶meter.shape)
),
));
continue;
}
if operation
.request_body
.as_ref()
.is_some_and(|body| body.required && !request_body_can_be_materialized(body, &definition_index))
{
diagnostics.push(skip_warning(
"openapi_required_body_without_example",
format!(
"Skipping {} {} because it requires a request body and no concrete example or supported schema is available to materialize.",
operation.method, operation.path
),
));
continue;
}
imported_operations.push(index);
let security = materialize_security(
operation,
&mut oauth_profiles,
&mut oauth_profile_names,
&mut used_names,
&mut diagnostics,
);
let response_target = response_assertion_target(
&plan.contract,
operation,
&definition_index,
&mut component_scalar_seeds,
&mut component_schema_seeds,
&mut synthetic_scalars,
&mut synthetic_schemas,
&mut used_names,
&mut diagnostics,
);
sections.push(materialize_operation(
&plan.contract,
&definition_index,
operation,
security.auth_profile.as_deref(),
&security.request_lines,
response_target.as_deref(),
&mut diagnostics,
));
}
let mut output = String::new();
output.push_str("name = Imported OpenAPI Collection\n");
output.push_str("description = Generated by `hen import` from an OpenAPI contract.\n");
let emitted_scalars = resolve_emittable_scalars(
&definition_index,
&component_scalar_seeds,
&component_schema_seeds,
);
let emitted_schemas = resolve_emittable_schemas(
&definition_index,
&component_schema_seeds,
&component_scalar_seeds,
);
let mut rendered_declarations = Vec::new();
for scalar in emitted_scalars {
rendered_declarations.push(render_scalar_definition(scalar));
}
for schema in emitted_schemas {
rendered_declarations.push(render_schema_definition(schema));
}
for profile in &oauth_profiles {
rendered_declarations.push(render_oauth_profile(profile));
}
for scalar in &synthetic_scalars {
rendered_declarations.push(render_scalar_definition(scalar));
}
for schema in &synthetic_schemas {
rendered_declarations.push(render_schema_definition(schema));
}
if !rendered_declarations.is_empty() {
output.push('\n');
output.push_str(&rendered_declarations.join("\n\n"));
}
for section in sections {
output.push_str("\n---\n\n");
output.push_str(§ion);
}
if !output.ends_with('\n') {
output.push('\n');
}
OpenApiMaterialization {
source: output,
imported_operations,
diagnostics,
}
}
fn materialize_operation(
document: &ContractDocument,
definition_index: &DefinitionIndex<'_>,
operation: &ContractOperation,
auth_profile: Option<&str>,
security_lines: &[String],
response_target: Option<&str>,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
) -> String {
let mut lines = Vec::new();
lines.push(operation_title(operation));
lines.push(String::new());
lines.push(format!("{} {}", operation.method, materialized_url(document, operation)));
if let Some(auth_profile) = auth_profile {
lines.push(format!("auth = {}", auth_profile));
}
lines.extend(security_lines.iter().cloned());
for parameter in &operation.parameters {
if !parameter_can_be_materialized(parameter) {
diagnostics.push(skip_warning(
"openapi_optional_parameter_unimplemented",
format!(
"Omitted optional {} parameter '{}' from {} {} because its OpenAPI serialization is unsupported (style={}, explode={}, allowReserved={}, shape={}).",
parameter.location,
parameter.name,
operation.method,
operation.path,
parameter.style,
parameter.explode,
parameter.allow_reserved,
parameter_shape_name(¶meter.shape)
),
));
continue;
}
match parameter.location.as_str() {
"query" if parameter.required || parameter.value.is_some() => {
if let Some(rendered_lines) = rendered_query_parameter_lines(parameter) {
lines.extend(rendered_lines);
}
}
"query" => diagnostics.push(skip_warning(
"openapi_optional_query_omitted",
format!(
"Omitted optional query parameter '{}' from {} {}.",
parameter.name, operation.method, operation.path
),
)),
"header" if parameter.required || parameter.value.is_some() => {
if let Some(line) = rendered_header_parameter_line(parameter) {
lines.push(line);
}
}
"header" => diagnostics.push(skip_warning(
"openapi_optional_header_omitted",
format!(
"Omitted optional header parameter '{}' from {} {}.",
parameter.name, operation.method, operation.path
),
)),
"cookie" if parameter.required || parameter.value.is_some() => {
if let Some(line) = rendered_cookie_parameter_line(parameter) {
lines.push(line);
}
}
"cookie" => diagnostics.push(skip_warning(
"openapi_optional_cookie_omitted",
format!(
"Omitted optional cookie parameter '{}' from {} {}.",
parameter.name, operation.method, operation.path
),
)),
_ => {}
}
}
if let Some(body) = materialized_request_body(operation, &operation.request_body, definition_index, diagnostics) {
lines.push(String::new());
match body {
RenderedRequestBody::Fenced { content_type, content } => {
lines.push(format!("~~~{}", fence_media_type(&content_type)));
lines.push(content);
lines.push("~~~".to_string());
}
RenderedRequestBody::FormFields(fields) => {
for (name, value) in fields {
lines.push(format!("~ {} = {}", name, value));
}
}
}
}
if let Some(target) = response_target {
lines.push(String::new());
lines.push(format!("^ & body === {target}"));
}
lines.join("\n")
}
fn materialized_request_body(
operation: &ContractOperation,
request_body: &Option<crate::contract::ContractRequestBody>,
definition_index: &DefinitionIndex<'_>,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
) -> Option<RenderedRequestBody> {
let body = request_body.as_ref()?;
if body.content_type == "multipart/form-data" {
if let Some(example) = &body.example {
if let Some(fields) = render_form_fields_from_example(example) {
return Some(RenderedRequestBody::FormFields(fields));
}
diagnostics.push(skip_warning(
"openapi_multipart_example_unimplemented",
format!(
"Omitted multipart body for {} {} because only object-shaped multipart examples can be materialized in this slice.",
operation.method, operation.path
),
));
return None;
}
let schema = body.schema.as_ref()?;
let value = synthesize_json_value_from_schema(schema, definition_index, 0)?;
let fields = render_form_fields_from_value(&value)?;
diagnostics.push(skip_warning(
"openapi_request_body_synthesized",
format!(
"Synthesized a minimal {} request body for {} {} from its schema because the OpenAPI spec did not provide a concrete example.",
body.content_type, operation.method, operation.path
),
));
return Some(RenderedRequestBody::FormFields(fields));
}
if body.content_type == "application/x-www-form-urlencoded" {
if let Some(example) = &body.example {
if let Some(fields) = render_form_fields_from_example(example) {
return Some(RenderedRequestBody::FormFields(fields));
}
return Some(RenderedRequestBody::Fenced {
content_type: body.content_type.clone(),
content: example.clone(),
});
}
let schema = body.schema.as_ref()?;
let value = synthesize_json_value_from_schema(schema, definition_index, 0)?;
let fields = render_form_fields_from_value(&value)?;
diagnostics.push(skip_warning(
"openapi_request_body_synthesized",
format!(
"Synthesized a minimal {} request body for {} {} from its schema because the OpenAPI spec did not provide a concrete example.",
body.content_type, operation.method, operation.path
),
));
return Some(RenderedRequestBody::FormFields(fields));
}
if let Some(example) = &body.example {
return Some(RenderedRequestBody::Fenced {
content_type: body.content_type.clone(),
content: example.clone(),
});
}
if !is_json_media_type(&body.content_type) {
return None;
}
let schema = body.schema.as_ref()?;
let value = synthesize_json_value_from_schema(schema, definition_index, 0)?;
diagnostics.push(skip_warning(
"openapi_request_body_synthesized",
format!(
"Synthesized a minimal {} request body for {} {} from its schema because the OpenAPI spec did not provide a concrete example.",
body.content_type, operation.method, operation.path
),
));
Some(RenderedRequestBody::Fenced {
content_type: body.content_type.clone(),
content: serde_json::to_string_pretty(&value).ok()?,
})
}
fn render_form_fields_from_example(example: &str) -> Option<Vec<(String, String)>> {
let value = serde_json::from_str::<Value>(example).ok()?;
render_form_fields_from_value(&value)
}
fn render_form_fields_from_value(value: &Value) -> Option<Vec<(String, String)>> {
let object = value.as_object()?;
let mut fields = Vec::with_capacity(object.len());
for (name, value) in object {
let rendered = match value {
Value::String(text) => text.clone(),
Value::Null => "null".to_string(),
Value::Bool(boolean) => boolean.to_string(),
Value::Number(number) => number.to_string(),
_ => return None,
};
fields.push((name.clone(), rendered));
}
Some(fields)
}
fn request_body_can_be_materialized(
body: &crate::contract::ContractRequestBody,
definition_index: &DefinitionIndex<'_>,
) -> bool {
match body.content_type.as_str() {
_ if is_json_media_type(&body.content_type) => body.example.is_some()
|| body
.schema
.as_ref()
.and_then(|schema| synthesize_json_value_from_schema(schema, definition_index, 0))
.is_some(),
"application/x-www-form-urlencoded" => body.example.is_some()
|| body
.schema
.as_ref()
.and_then(|schema| synthesize_json_value_from_schema(schema, definition_index, 0))
.and_then(|value| render_form_fields_from_value(&value))
.is_some(),
"multipart/form-data" => body
.example
.as_ref()
.and_then(|example| render_form_fields_from_example(example))
.is_some()
|| body
.schema
.as_ref()
.and_then(|schema| synthesize_json_value_from_schema(schema, definition_index, 0))
.and_then(|value| render_form_fields_from_value(&value))
.is_some(),
"text/plain" => body.example.is_some(),
_ => body.example.is_some(),
}
}
fn synthesize_json_value_from_schema(
schema: &ContractResponseSchema,
definition_index: &DefinitionIndex<'_>,
depth: usize,
) -> Option<Value> {
if depth > 8 {
return None;
}
match schema {
ContractResponseSchema::Scalar(expression) => synthesize_json_value_from_scalar_expression(expression),
ContractResponseSchema::Type(value_type) => {
synthesize_json_value_from_type_ref(value_type, definition_index, depth)
}
ContractResponseSchema::Object { fields } => synthesize_json_object(fields, definition_index, depth),
}
}
fn synthesize_json_value_from_type_ref(
value_type: &ContractTypeRef,
definition_index: &DefinitionIndex<'_>,
depth: usize,
) -> Option<Value> {
if value_type.array_depth > 0 {
let mut item_type = value_type.clone();
item_type.array_depth = 0;
let item = synthesize_json_value_from_type_ref(&item_type, definition_index, depth + 1)
.or_else(|| value_type.nullable.then_some(Value::Null))?;
return Some(wrap_array_value(item, value_type.array_depth));
}
if let Some(value) = representative_value_for_target(&value_type.target) {
return Some(value);
}
if let Some(scalar) = definition_index.scalars.get(value_type.target.as_str()) {
return synthesize_json_value_from_scalar_expression(&scalar.expression)
.or_else(|| value_type.nullable.then_some(Value::Null));
}
if let Some(schema) = definition_index.schemas.get(value_type.target.as_str()) {
let value = match &schema.shape {
ContractSchemaShape::Object { fields } => {
synthesize_json_object(fields, definition_index, depth + 1)
}
ContractSchemaShape::Array(item_type) => synthesize_json_value_from_type_ref(
&item_type.clone().array(),
definition_index,
depth + 1,
),
ContractSchemaShape::Alias(target) => {
synthesize_json_value_from_type_ref(target, definition_index, depth + 1)
}
ContractSchemaShape::AllOf(targets) => {
synthesize_json_value_from_all_of_targets(targets, definition_index, depth + 1)
}
ContractSchemaShape::OneOf(targets) | ContractSchemaShape::AnyOf(targets) => targets
.iter()
.find_map(|target| {
synthesize_json_value_from_named_target(target, definition_index, depth + 1)
}),
ContractSchemaShape::Not(_) => None,
ContractSchemaShape::Discriminator { field, branches } => {
synthesize_json_value_from_discriminator(field, branches, definition_index, depth + 1)
}
};
return value.or_else(|| value_type.nullable.then_some(Value::Null));
}
value_type.nullable.then_some(Value::Null)
}
fn synthesize_json_value_from_named_target(
target: &str,
definition_index: &DefinitionIndex<'_>,
depth: usize,
) -> Option<Value> {
synthesize_json_value_from_type_ref(&ContractTypeRef::new(target), definition_index, depth)
}
fn synthesize_json_value_from_all_of_targets(
targets: &[String],
definition_index: &DefinitionIndex<'_>,
depth: usize,
) -> Option<Value> {
let mut merged = Map::new();
let mut saw_object = false;
for target in targets {
let value = synthesize_json_value_from_named_target(target, definition_index, depth)?;
match value {
Value::Object(object) => {
saw_object = true;
for (key, value) in object {
merged.insert(key, value);
}
}
_ if !saw_object && targets.len() == 1 => {
return synthesize_json_value_from_named_target(target, definition_index, depth);
}
_ => return None,
}
}
saw_object.then_some(Value::Object(merged))
}
fn synthesize_json_value_from_discriminator(
field: &str,
branches: &[ContractDiscriminatorBranch],
definition_index: &DefinitionIndex<'_>,
depth: usize,
) -> Option<Value> {
let branch = branches.first()?;
let mut value = synthesize_json_value_from_named_target(&branch.target, definition_index, depth)?;
let Value::Object(object) = &mut value else {
return None;
};
object.insert(field.to_string(), Value::String(branch.tag.clone()));
Some(value)
}
fn synthesize_json_object(
fields: &[ContractSchemaField],
definition_index: &DefinitionIndex<'_>,
depth: usize,
) -> Option<Value> {
let mut object = Map::new();
for field in fields.iter().filter(|field| field.required) {
let value = synthesize_json_value_from_type_ref(&field.value_type, definition_index, depth + 1)?;
object.insert(field.name.clone(), value);
}
Some(Value::Object(object))
}
fn wrap_array_value(value: Value, array_depth: usize) -> Value {
let mut current = value;
for _ in 0..array_depth {
current = Value::Array(vec![current]);
}
current
}
fn synthesize_json_value_from_scalar_expression(expression: &str) -> Option<Value> {
if let Some(value) = first_enum_literal(expression) {
return Some(value);
}
let base = expression.split('&').next()?.trim();
representative_value_for_target(base)
}
fn first_enum_literal(expression: &str) -> Option<Value> {
let start = expression.find("enum(")? + 5;
let rest = &expression[start..];
let end = rest.find(')')?;
let first = rest[..end].split(',').next()?.trim();
parse_scalar_literal_value(first)
}
fn parse_scalar_literal_value(value: &str) -> Option<Value> {
if value == "true" {
return Some(Value::Bool(true));
}
if value == "false" {
return Some(Value::Bool(false));
}
if value == "null" {
return Some(Value::Null);
}
if (value.starts_with('"') && value.ends_with('"')) || (value.starts_with('\'') && value.ends_with('\'')) {
return Some(Value::String(value[1..value.len() - 1].to_string()));
}
if let Ok(integer) = value.parse::<i64>() {
return Some(Value::Number(Number::from(integer)));
}
if let Ok(number) = value.parse::<f64>() {
return Number::from_f64(number).map(Value::Number);
}
None
}
fn representative_value_for_target(target: &str) -> Option<Value> {
match target {
"string" => Some(Value::String("example".to_string())),
"integer" => Some(Value::Number(Number::from(1))),
"number" | "NUMBER" => Some(Value::Number(Number::from(1))),
"boolean" => Some(Value::Bool(true)),
"null" => Some(Value::Null),
"UUID" => Some(Value::String("00000000-0000-0000-0000-000000000000".to_string())),
"EMAIL" => Some(Value::String("user@example.com".to_string())),
"DATE" => Some(Value::String("2024-01-01".to_string())),
"DATE_TIME" => Some(Value::String("2024-01-01T00:00:00Z".to_string())),
"TIME" => Some(Value::String("00:00:00Z".to_string())),
"URI" => Some(Value::String("https://example.com".to_string())),
_ => None,
}
}
fn materialize_security(
operation: &ContractOperation,
oauth_profiles: &mut Vec<MaterializedOAuthProfile>,
oauth_profile_names: &mut BTreeMap<OAuthProfileKey, String>,
used_names: &mut BTreeSet<String>,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
) -> OperationSecurityMaterialization {
let Some(selected) = operation.security.first() else {
return OperationSecurityMaterialization::default();
};
if selected.is_empty() {
return OperationSecurityMaterialization::default();
}
if operation.security.len() > 1 {
diagnostics.push(skip_warning(
"openapi_security_alternatives_reduced",
format!(
"Materialized only the first supported security alternative for {} {} because Hen requests cannot preserve OpenAPI auth alternatives.",
operation.method, operation.path
),
));
}
let mut auth_profile = None;
let mut request_lines = Vec::new();
for requirement in selected {
match &requirement.kind {
ContractSecuritySchemeKind::OAuthClientCredentials { token_url, scopes } => {
if auth_profile.is_some() {
diagnostics.push(skip_warning(
"openapi_multiple_oauth_schemes_reduced",
format!(
"Materialized only the first OAuth profile for {} {} because Hen requests support a single auth directive.",
operation.method, operation.path
),
));
continue;
}
let key = OAuthProfileKey {
scheme_name: requirement.scheme_name.clone(),
token_url: token_url.clone(),
scope: if scopes.is_empty() {
None
} else {
Some(scopes.join(" "))
},
};
let profile_name = if let Some(existing) = oauth_profile_names.get(&key) {
existing.clone()
} else {
let profile_name = unique_oauth_profile_name(&requirement.scheme_name, used_names);
oauth_profile_names.insert(key.clone(), profile_name.clone());
oauth_profiles.push(MaterializedOAuthProfile {
name: profile_name.clone(),
token_url: token_url.clone(),
scope: key.scope.clone(),
client_id_env: oauth_secret_env_name(&profile_name, "CLIENT_ID"),
client_secret_env: oauth_secret_env_name(&profile_name, "CLIENT_SECRET"),
});
diagnostics.push(skip_warning(
"openapi_oauth_placeholders_generated",
format!(
"Materialized OAuth profile '{}' for scheme '{}' with unresolved client credentials backed by secret.env placeholders.",
profile_name, requirement.scheme_name
),
));
profile_name
};
auth_profile = Some(profile_name);
}
_ => request_lines.push(materialize_security_requirement(requirement)),
}
}
OperationSecurityMaterialization {
auth_profile,
request_lines,
}
}
fn materialize_security_requirement(requirement: &ContractSecurityRequirement) -> String {
match &requirement.kind {
ContractSecuritySchemeKind::ApiKeyHeader { name } => {
format!("* {} = [[ {} ]]", name, security_prompt_name(requirement))
}
ContractSecuritySchemeKind::ApiKeyQuery { name } => {
format!("? {} = [[ {} ]]", name, security_prompt_name(requirement))
}
ContractSecuritySchemeKind::ApiKeyCookie { name } => {
format!("@ {} = [[ {} ]]", name, security_prompt_name(requirement))
}
ContractSecuritySchemeKind::HttpBasic => format!(
"* Authorization = Basic [[ {} ]]",
security_prompt_name(requirement)
),
ContractSecuritySchemeKind::HttpBearer => format!(
"* Authorization = Bearer [[ {} ]]",
security_prompt_name(requirement)
),
ContractSecuritySchemeKind::OAuthClientCredentials { .. } => {
unreachable!("oauth client credentials are materialized as auth profiles")
}
}
}
fn security_prompt_name(requirement: &ContractSecurityRequirement) -> String {
match &requirement.kind {
ContractSecuritySchemeKind::ApiKeyHeader { name }
| ContractSecuritySchemeKind::ApiKeyQuery { name }
| ContractSecuritySchemeKind::ApiKeyCookie { name } => sanitize_placeholder_name(name),
ContractSecuritySchemeKind::HttpBasic => {
sanitize_placeholder_name(&format!("{}_credentials", requirement.scheme_name))
}
ContractSecuritySchemeKind::HttpBearer => {
sanitize_placeholder_name(&format!("{}_token", requirement.scheme_name))
}
ContractSecuritySchemeKind::OAuthClientCredentials { .. } => {
sanitize_placeholder_name(&format!("{}_oauth", requirement.scheme_name))
}
}
}
fn render_oauth_profile(profile: &MaterializedOAuthProfile) -> String {
let mut lines = vec![
format!("oauth {}", profile.name),
" grant = client_credentials".to_string(),
format!(" token_url = {}", profile.token_url),
format!(" client_id = secret.env(\"{}\")", profile.client_id_env),
format!(" client_secret = secret.env(\"{}\")", profile.client_secret_env),
];
if let Some(scope) = &profile.scope {
lines.push(format!(" scope = {}", scope));
}
lines.join("\n")
}
fn unique_oauth_profile_name(base: &str, used_names: &mut BTreeSet<String>) -> String {
let sanitized = sanitize_identifier_name(base);
let root = if sanitized.is_empty() {
"imported_oauth".to_string()
} else {
sanitized
};
let mut candidate = root.clone();
let mut counter = 2;
while used_names.contains(&candidate) {
candidate = format!("{}{}", root, counter);
counter += 1;
}
used_names.insert(candidate.clone());
candidate
}
fn oauth_secret_env_name(profile_name: &str, suffix: &str) -> String {
format!(
"HEN_OPENAPI_{}_{}",
sanitize_placeholder_name(profile_name).to_ascii_uppercase(),
suffix
)
}
fn sanitize_identifier_name(input: &str) -> String {
let sanitized = sanitize_placeholder_name(input);
if sanitized.is_empty() {
return sanitized;
}
if sanitized.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
format!("_{}", sanitized)
} else {
sanitized
}
}
fn response_assertion_target(
_document: &ContractDocument,
operation: &ContractOperation,
definition_index: &DefinitionIndex<'_>,
component_scalar_seeds: &mut BTreeSet<String>,
component_schema_seeds: &mut BTreeSet<String>,
synthetic_scalars: &mut Vec<ContractScalarDefinition>,
synthetic_schemas: &mut Vec<ContractSchemaDefinition>,
used_names: &mut BTreeSet<String>,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
) -> Option<String> {
let response_body = operation.response_body.as_ref()?;
let schema = response_body.schema.as_ref()?;
match schema {
ContractResponseSchema::Type(value_type) => {
if value_type.array_depth == 0 && is_direct_assertion_target(value_type, definition_index) {
seed_type_ref(value_type, definition_index, component_scalar_seeds, component_schema_seeds);
return Some(value_type.target.clone());
}
if !type_ref_is_emittable(value_type, definition_index) {
diagnostics.push(skip_warning(
"openapi_response_schema_omitted",
format!(
"Omitted a response assertion for {} {} because its response schema references unsupported component targets.",
operation.method, operation.path
),
));
return None;
}
if value_type.array_depth > 0 {
seed_type_ref(value_type, definition_index, component_scalar_seeds, component_schema_seeds);
let name = unique_response_name(operation, used_names);
synthetic_schemas.push(ContractSchemaDefinition {
name: name.clone(),
shape: ContractSchemaShape::Array(value_type.clone()),
});
return Some(name);
}
if is_primitive_target(&value_type.target) && !value_type.nullable {
let name = unique_response_name(operation, used_names);
synthetic_scalars.push(ContractScalarDefinition {
name: name.clone(),
expression: render_type_ref(value_type),
});
return Some(name);
}
diagnostics.push(skip_warning(
"openapi_response_schema_omitted",
format!(
"Omitted a response assertion for {} {} because the response schema cannot be expressed as a Hen target yet.",
operation.method, operation.path
),
));
None
}
ContractResponseSchema::Scalar(expression) => {
let name = unique_response_name(operation, used_names);
synthetic_scalars.push(ContractScalarDefinition {
name: name.clone(),
expression: expression.clone(),
});
Some(name)
}
ContractResponseSchema::Object { fields } => {
if !fields_are_emittable(fields, definition_index) {
diagnostics.push(skip_warning(
"openapi_response_schema_omitted",
format!(
"Omitted a response assertion for {} {} because the response object contains unsupported field targets.",
operation.method, operation.path
),
));
return None;
}
seed_fields(fields, definition_index, component_scalar_seeds, component_schema_seeds);
let name = unique_response_name(operation, used_names);
synthetic_schemas.push(ContractSchemaDefinition {
name: name.clone(),
shape: ContractSchemaShape::Object { fields: fields.clone() },
});
Some(name)
}
}
}
fn resolve_emittable_scalars<'a>(
definition_index: &'a DefinitionIndex<'a>,
component_scalar_seeds: &BTreeSet<String>,
component_schema_seeds: &BTreeSet<String>,
) -> Vec<&'a ContractScalarDefinition> {
let mut seeds = component_scalar_seeds.clone();
for name in component_schema_seeds {
collect_component_dependencies(name, definition_index, &mut seeds, &mut BTreeSet::new());
}
let mut names = seeds
.into_iter()
.filter(|name| definition_index.scalars.contains_key(name.as_str()))
.collect::<Vec<_>>();
names.sort();
names
.into_iter()
.filter_map(|name| definition_index.scalars.get(name.as_str()).copied())
.collect()
}
fn resolve_emittable_schemas<'a>(
definition_index: &'a DefinitionIndex<'a>,
component_schema_seeds: &BTreeSet<String>,
component_scalar_seeds: &BTreeSet<String>,
) -> Vec<&'a ContractSchemaDefinition> {
let mut reachable = BTreeSet::new();
let mut referenced_scalars = component_scalar_seeds.clone();
for name in component_schema_seeds {
collect_component_dependencies(name, definition_index, &mut referenced_scalars, &mut reachable);
}
let mut names = reachable.into_iter().collect::<Vec<_>>();
names.sort();
names
.into_iter()
.filter_map(|name| definition_index.schemas.get(name.as_str()).copied())
.collect()
}
fn collect_component_dependencies(
name: &str,
definition_index: &DefinitionIndex<'_>,
scalar_names: &mut BTreeSet<String>,
schema_names: &mut BTreeSet<String>,
) {
let mut visiting = BTreeSet::new();
collect_component_dependencies_inner(
name,
definition_index,
scalar_names,
schema_names,
&mut visiting,
);
}
fn collect_component_dependencies_inner(
name: &str,
definition_index: &DefinitionIndex<'_>,
scalar_names: &mut BTreeSet<String>,
schema_names: &mut BTreeSet<String>,
visiting: &mut BTreeSet<String>,
) {
if !visiting.insert(name.to_string()) {
return;
}
if definition_index.scalars.contains_key(name) {
scalar_names.insert(name.to_string());
visiting.remove(name);
return;
}
let Some(schema) = definition_index.schemas.get(name).copied() else {
visiting.remove(name);
return;
};
if !schema_definition_is_emittable(schema, definition_index) {
visiting.remove(name);
return;
}
schema_names.insert(name.to_string());
for dependency in schema_named_dependencies(schema) {
collect_component_dependencies_inner(
&dependency,
definition_index,
scalar_names,
schema_names,
visiting,
);
}
visiting.remove(name);
}
fn seed_type_ref(
value_type: &ContractTypeRef,
definition_index: &DefinitionIndex<'_>,
component_scalar_seeds: &mut BTreeSet<String>,
component_schema_seeds: &mut BTreeSet<String>,
) {
if definition_index.scalars.contains_key(value_type.target.as_str()) {
component_scalar_seeds.insert(value_type.target.clone());
} else if definition_index.schemas.contains_key(value_type.target.as_str()) {
component_schema_seeds.insert(value_type.target.clone());
}
}
fn seed_fields(
fields: &[ContractSchemaField],
definition_index: &DefinitionIndex<'_>,
component_scalar_seeds: &mut BTreeSet<String>,
component_schema_seeds: &mut BTreeSet<String>,
) {
for field in fields {
seed_type_ref(
&field.value_type,
definition_index,
component_scalar_seeds,
component_schema_seeds,
);
}
}
fn schema_definition_is_emittable(
definition: &ContractSchemaDefinition,
definition_index: &DefinitionIndex<'_>,
) -> bool {
match &definition.shape {
ContractSchemaShape::Object { fields } => fields_are_emittable(fields, definition_index),
ContractSchemaShape::Array(value_type) => type_ref_is_emittable(value_type, definition_index),
ContractSchemaShape::Alias(value_type) => type_ref_is_emittable(value_type, definition_index),
ContractSchemaShape::AllOf(targets)
| ContractSchemaShape::OneOf(targets)
| ContractSchemaShape::AnyOf(targets) => targets
.iter()
.all(|target| named_target_is_emittable(target, definition_index)),
ContractSchemaShape::Not(target) => named_target_is_emittable(target, definition_index),
ContractSchemaShape::Discriminator { branches, .. } => branches.iter().all(|branch| {
named_target_is_emittable(&branch.target, definition_index)
}),
}
}
fn fields_are_emittable(
fields: &[ContractSchemaField],
definition_index: &DefinitionIndex<'_>,
) -> bool {
fields
.iter()
.all(|field| type_ref_is_emittable(&field.value_type, definition_index))
}
fn type_ref_is_emittable(
value_type: &ContractTypeRef,
definition_index: &DefinitionIndex<'_>,
) -> bool {
is_builtin_target(&value_type.target)
|| is_primitive_target(&value_type.target)
|| definition_index.scalars.contains_key(value_type.target.as_str())
|| definition_index.schemas.contains_key(value_type.target.as_str())
}
fn named_target_is_emittable(target: &str, definition_index: &DefinitionIndex<'_>) -> bool {
is_builtin_target(target)
|| is_primitive_target(target)
|| definition_index.scalars.contains_key(target)
|| definition_index.schemas.contains_key(target)
}
fn is_direct_assertion_target(
value_type: &ContractTypeRef,
definition_index: &DefinitionIndex<'_>,
) -> bool {
!value_type.nullable
&& (is_builtin_target(&value_type.target)
|| definition_index.scalars.contains_key(value_type.target.as_str())
|| definition_index.schemas.contains_key(value_type.target.as_str()))
}
fn schema_named_dependencies(definition: &ContractSchemaDefinition) -> Vec<String> {
match &definition.shape {
ContractSchemaShape::Object { fields } => fields
.iter()
.filter_map(|field| named_dependency(&field.value_type))
.collect(),
ContractSchemaShape::Array(value_type) => named_dependency(value_type).into_iter().collect(),
ContractSchemaShape::Alias(value_type) => named_dependency(value_type).into_iter().collect(),
ContractSchemaShape::AllOf(targets)
| ContractSchemaShape::OneOf(targets)
| ContractSchemaShape::AnyOf(targets) => targets
.iter()
.filter(|target| !is_builtin_target(target) && !is_primitive_target(target))
.cloned()
.collect(),
ContractSchemaShape::Not(target) => (!is_builtin_target(target) && !is_primitive_target(target))
.then(|| target.clone())
.into_iter()
.collect(),
ContractSchemaShape::Discriminator { branches, .. } => branches
.iter()
.map(|branch| branch.target.as_str())
.filter(|target| !is_builtin_target(target) && !is_primitive_target(target))
.map(ToOwned::to_owned)
.collect(),
}
}
fn named_dependency(value_type: &ContractTypeRef) -> Option<String> {
(!is_builtin_target(&value_type.target) && !is_primitive_target(&value_type.target))
.then(|| value_type.target.clone())
}
fn render_scalar_definition(definition: &ContractScalarDefinition) -> String {
format!("scalar {} = {}", definition.name, definition.expression)
}
fn render_schema_definition(definition: &ContractSchemaDefinition) -> String {
match &definition.shape {
ContractSchemaShape::Alias(value_type) | ContractSchemaShape::Array(value_type) => {
format!("schema {} = {}", definition.name, render_type_ref(value_type))
}
ContractSchemaShape::AllOf(targets) => {
format!("schema {} = allOf({})", definition.name, targets.join(", "))
}
ContractSchemaShape::OneOf(targets) => {
format!("schema {} = oneOf({})", definition.name, targets.join(", "))
}
ContractSchemaShape::AnyOf(targets) => {
format!("schema {} = anyOf({})", definition.name, targets.join(", "))
}
ContractSchemaShape::Not(target) => {
format!("schema {} = not({})", definition.name, target)
}
ContractSchemaShape::Discriminator { field, branches } => {
let mut rendered = Vec::with_capacity(branches.len() + 2);
rendered.push(format!("schema {} = discriminator({},", definition.name, field));
for branch in branches {
rendered.push(format!(
" {}: {},",
serde_json::to_string(&branch.tag).unwrap_or_else(|_| format!("\"{}\"", branch.tag)),
branch.target
));
}
if let Some(last) = rendered.last_mut() {
if last.ends_with(',') {
last.pop();
}
}
rendered.push(")".to_string());
rendered.join("\n")
}
ContractSchemaShape::Object { fields } => {
let mut rendered = Vec::with_capacity(fields.len() + 2);
rendered.push(format!("schema {} {{", definition.name));
for field in fields {
rendered.push(format!(
" {}{}: {}",
field.name,
if field.required { "" } else { "?" },
render_type_ref(&field.value_type)
));
}
rendered.push("}".to_string());
rendered.join("\n")
}
}
}
fn render_type_ref(value_type: &ContractTypeRef) -> String {
let mut rendered = value_type.target.clone();
for _ in 0..value_type.array_depth {
rendered.push_str("[]");
}
if value_type.nullable {
rendered.push('?');
}
rendered
}
fn known_definition_names(document: &ContractDocument) -> BTreeSet<String> {
let mut names = document
.scalars
.iter()
.map(|definition| definition.name.clone())
.collect::<BTreeSet<_>>();
names.extend(document.schemas.iter().map(|definition| definition.name.clone()));
names
}
fn unique_response_name(operation: &ContractOperation, used_names: &mut BTreeSet<String>) -> String {
let base = sanitize_type_name(
&operation
.operation_id
.clone()
.unwrap_or_else(|| operation_title(operation)),
);
let root = if base.is_empty() {
"ImportedResponse".to_string()
} else {
format!("{}Response", base)
};
let mut candidate = root.clone();
let mut counter = 2;
while used_names.contains(&candidate) {
candidate = format!("{}{}", root, counter);
counter += 1;
}
used_names.insert(candidate.clone());
candidate
}
fn sanitize_type_name(input: &str) -> String {
let mut output = String::with_capacity(input.len());
for ch in input.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
output.push(ch);
}
}
if output.is_empty() {
return String::new();
}
if output.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
output.insert(0, '_');
}
output
}
fn is_builtin_target(target: &str) -> bool {
matches!(target, "UUID" | "EMAIL" | "NUMBER" | "DATE" | "DATE_TIME" | "TIME" | "URI")
}
fn is_primitive_target(target: &str) -> bool {
matches!(target, "string" | "integer" | "number" | "boolean" | "null")
}
struct DefinitionIndex<'a> {
scalars: BTreeMap<&'a str, &'a ContractScalarDefinition>,
schemas: BTreeMap<&'a str, &'a ContractSchemaDefinition>,
}
#[derive(Debug, Clone, Default)]
struct OperationSecurityMaterialization {
auth_profile: Option<String>,
request_lines: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct MaterializedOAuthProfile {
name: String,
token_url: String,
scope: Option<String>,
client_id_env: String,
client_secret_env: String,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct OAuthProfileKey {
scheme_name: String,
token_url: String,
scope: Option<String>,
}
enum RenderedRequestBody {
Fenced { content_type: String, content: String },
FormFields(Vec<(String, String)>),
}
impl<'a> DefinitionIndex<'a> {
fn new(document: &'a ContractDocument) -> Self {
Self {
scalars: document
.scalars
.iter()
.map(|definition| (definition.name.as_str(), definition))
.collect(),
schemas: document
.schemas
.iter()
.map(|definition| (definition.name.as_str(), definition))
.collect(),
}
}
}
fn materialized_url(document: &ContractDocument, operation: &ContractOperation) -> String {
let base = base_url(&operation.servers, &document.servers);
let path = materialized_path(&operation.path, &operation.parameters);
if base.ends_with('/') && path.starts_with('/') {
format!("{}{}", base.trim_end_matches('/'), path)
} else {
format!("{}{}", base, path)
}
}
fn base_url(operation_servers: &[String], document_servers: &[String]) -> String {
if let Some(server) = operation_servers.first().or_else(|| document_servers.first()) {
if (server.starts_with("http://") || server.starts_with("https://")) && !server.contains('{') {
return server.trim_end_matches('/').to_string();
}
if server.starts_with('/') {
return format!("[[ BASE_URL ]]{}", server.trim_end_matches('/'));
}
}
"[[ BASE_URL ]]".to_string()
}
fn materialized_path(path: &str, parameters: &[ContractParameter]) -> String {
let mut rendered = path.to_string();
for parameter in parameters.iter().filter(|parameter| parameter.location == "path") {
if !parameter_can_be_materialized(parameter) {
continue;
}
rendered = rendered.replace(
&format!("{{{}}}", parameter.name),
&rendered_path_parameter(parameter),
);
}
rendered
}
fn rendered_path_parameter(parameter: &ContractParameter) -> String {
if let Some(values) = parameter_array_values(parameter) {
return match parameter.style.as_str() {
"label" if parameter.explode => format!(".{}", values.join(".")),
"label" => format!(".{}", values.join(",")),
"matrix" if parameter.explode => values
.into_iter()
.map(|value| format!(";{}={value}", parameter.name))
.collect::<String>(),
"matrix" => format!(";{}={}", parameter.name, values.join(",")),
_ => values.join(","),
};
}
let prompt = parameter_value(parameter);
match parameter.style.as_str() {
"label" => format!(".{prompt}"),
"matrix" => format!(";{}={prompt}", parameter.name),
_ => prompt,
}
}
fn operation_title(operation: &ContractOperation) -> String {
operation
.summary
.clone()
.or_else(|| operation.operation_id.clone())
.unwrap_or_else(|| format!("{} {}", operation.method, operation.path))
}
fn prompt_name(parameter: &ContractParameter) -> String {
sanitize_placeholder_name(¶meter.name)
}
fn parameter_value(parameter: &ContractParameter) -> String {
parameter
.value
.clone()
.unwrap_or_else(|| format!("[[ {} ]]", prompt_name(parameter)))
}
fn rendered_query_parameter_lines(parameter: &ContractParameter) -> Option<Vec<String>> {
match parameter.shape {
ContractParameterShape::Scalar => Some(vec![format!(
"? {} = {}",
parameter.name,
parameter_value(parameter)
)]),
ContractParameterShape::Array => {
let values = parameter_array_values(parameter)?;
match parameter.style.as_str() {
"form" if parameter.explode => Some(
values
.into_iter()
.map(|value| format!("? {} = {}", parameter.name, value))
.collect(),
),
"form" => Some(vec![format!("? {} = {}", parameter.name, values.join(","))]),
"spaceDelimited" if !parameter.explode => {
Some(vec![format!("? {} = {}", parameter.name, values.join(" "))])
}
"pipeDelimited" if !parameter.explode => {
Some(vec![format!("? {} = {}", parameter.name, values.join("|"))])
}
_ => None,
}
}
ContractParameterShape::Object => {
let fields = parameter_object_fields(parameter)?;
match parameter.style.as_str() {
"deepObject" if parameter.explode => Some(
fields
.into_iter()
.map(|(field, value)| format!("? {}[{}] = {}", parameter.name, field, value))
.collect(),
),
"form" if parameter.explode => Some(
fields
.into_iter()
.map(|(field, value)| format!("? {} = {}", field, value))
.collect(),
),
"form" => Some(vec![format!(
"? {} = {}",
parameter.name,
fields
.into_iter()
.flat_map(|(field, value)| [field, value])
.collect::<Vec<_>>()
.join(",")
)]),
_ => None,
}
}
}
}
fn rendered_header_parameter_line(parameter: &ContractParameter) -> Option<String> {
match parameter.shape {
ContractParameterShape::Scalar => Some(format!(
"* {} = {}",
parameter.name,
parameter_value(parameter)
)),
ContractParameterShape::Array => Some(format!(
"* {} = {}",
parameter.name,
parameter_array_values(parameter)?.join(",")
)),
ContractParameterShape::Object => None,
}
}
fn rendered_cookie_parameter_line(parameter: &ContractParameter) -> Option<String> {
match parameter.shape {
ContractParameterShape::Scalar => Some(format!(
"@ {} = {}",
parameter.name,
parameter_value(parameter)
)),
ContractParameterShape::Array => Some(format!(
"@ {} = {}",
parameter.name,
parameter_array_values(parameter)?.join(",")
)),
ContractParameterShape::Object => None,
}
}
fn parameter_array_values(parameter: &ContractParameter) -> Option<Vec<String>> {
let value = parameter.value.as_ref()?;
let parsed = serde_json::from_str::<Value>(value).ok()?;
let items = parsed.as_array()?;
items
.iter()
.map(|item| match item {
Value::String(text) => Some(text.clone()),
Value::Null => Some("null".to_string()),
Value::Bool(boolean) => Some(boolean.to_string()),
Value::Number(number) => Some(number.to_string()),
_ => None,
})
.collect()
}
fn parameter_object_fields(parameter: &ContractParameter) -> Option<Vec<(String, String)>> {
let value = parameter.value.as_ref()?;
let parsed = serde_json::from_str::<Value>(value).ok()?;
let object = parsed.as_object()?;
let mut fields = object
.iter()
.map(|(field, value)| {
let rendered = match value {
Value::String(text) => Some(text.clone()),
Value::Null => Some("null".to_string()),
Value::Bool(boolean) => Some(boolean.to_string()),
Value::Number(number) => Some(number.to_string()),
_ => None,
}?;
Some((field.clone(), rendered))
})
.collect::<Option<Vec<_>>>()?;
fields.sort_by(|left, right| left.0.cmp(&right.0));
Some(fields)
}
fn parameter_can_be_materialized(parameter: &ContractParameter) -> bool {
if parameter.allow_reserved {
return false;
}
match parameter.location.as_str() {
"path" => {
match parameter.shape {
ContractParameterShape::Scalar => {
matches!(parameter.style.as_str(), "simple" | "label" | "matrix")
}
ContractParameterShape::Array => {
matches!(parameter.style.as_str(), "simple" | "label" | "matrix")
&& parameter_array_values(parameter).is_some()
}
ContractParameterShape::Object => false,
}
}
"query" => match parameter.shape {
ContractParameterShape::Scalar => parameter.style == "form",
ContractParameterShape::Array => {
matches!(parameter.style.as_str(), "form" | "spaceDelimited" | "pipeDelimited")
&& parameter_array_values(parameter).is_some()
&& (parameter.style == "form" || !parameter.explode)
}
ContractParameterShape::Object => {
matches!(parameter.style.as_str(), "deepObject" | "form")
&& parameter_object_fields(parameter).is_some()
&& (parameter.style == "form" || parameter.explode)
}
},
"header" => {
match parameter.shape {
ContractParameterShape::Scalar => parameter.style == "simple",
ContractParameterShape::Array => {
parameter.style == "simple" && parameter_array_values(parameter).is_some()
}
ContractParameterShape::Object => false,
}
}
"cookie" => {
match parameter.shape {
ContractParameterShape::Scalar => parameter.style == "form",
ContractParameterShape::Array => {
parameter.style == "form"
&& !parameter.explode
&& parameter_array_values(parameter).is_some()
}
ContractParameterShape::Object => false,
}
}
_ => false,
}
}
fn parameter_shape_name(shape: &ContractParameterShape) -> &'static str {
match shape {
ContractParameterShape::Scalar => "scalar",
ContractParameterShape::Array => "array",
ContractParameterShape::Object => "object",
}
}
fn sanitize_placeholder_name(input: &str) -> String {
let mut output = String::with_capacity(input.len());
for ch in input.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
output.push(ch);
} else {
output.push('_');
}
}
if output.is_empty() {
"VALUE".to_string()
} else {
output
}
}
fn fence_media_type(content_type: &str) -> &str {
if is_json_media_type(content_type) {
"json"
} else {
content_type
}
}
fn is_json_media_type(content_type: &str) -> bool {
let normalized = content_type.trim().to_ascii_lowercase();
normalized == "application/json" || normalized.ends_with("+json")
}
fn skip_warning(code: &'static str, message: String) -> OpenApiCapabilityDiagnostic {
OpenApiCapabilityDiagnostic {
code,
level: OpenApiSupportLevel::Warning,
message,
}
}
#[cfg(test)]
mod tests {
use crate::{
contract::{
ContractDocument, ContractOperation, ContractParameter, ContractParameterShape,
ContractRequestBody, ContractResponseBody, ContractResponseSchema,
ContractScalarDefinition, ContractSchemaDefinition, ContractSchemaField,
ContractSchemaShape, ContractSecurityRequirement, ContractSecuritySchemeKind,
ContractTypeRef,
},
openapi::{OpenApiImportBoundary, OpenApiImportPlan},
};
use super::*;
fn base_plan() -> OpenApiImportPlan {
OpenApiImportPlan {
boundary: Some(OpenApiImportBoundary::ContractOnly),
contract: ContractDocument {
servers: vec!["https://api.example.com".to_string()],
scalars: Vec::new(),
schemas: Vec::new(),
operations: vec![ContractOperation {
operation_id: Some("getPet".to_string()),
summary: Some("Get Pet".to_string()),
method: "GET".to_string(),
path: "/pets/{petId}".to_string(),
servers: vec!["https://api.example.com".to_string()],
tags: vec!["pets".to_string()],
parameters: vec![
ContractParameter {
name: "petId".to_string(),
location: "path".to_string(),
required: true,
style: "simple".to_string(),
explode: false,
allow_reserved: false,
shape: ContractParameterShape::Scalar,
value: None,
},
ContractParameter {
name: "trace-id".to_string(),
location: "header".to_string(),
required: true,
style: "simple".to_string(),
explode: false,
allow_reserved: false,
shape: ContractParameterShape::Scalar,
value: None,
},
],
request_body: None,
response_body: None,
security: Vec::new(),
source_span: None,
}],
},
selected_operations: vec![0],
diagnostics: Vec::new(),
}
}
#[test]
fn materializes_basic_request_stub() {
let materialized = materialize_import(&base_plan());
assert_eq!(materialized.imported_operations, vec![0]);
assert!(materialized.source.contains("Get Pet"));
assert!(materialized
.source
.contains("GET https://api.example.com/pets/[[ petId ]]"));
assert!(materialized.source.contains("* trace-id = [[ trace_id ]]"));
}
#[test]
fn skips_required_body_without_example() {
let mut plan = base_plan();
plan.contract.operations[0].method = "POST".to_string();
plan.contract.operations[0].request_body = Some(ContractRequestBody {
content_type: "application/json".to_string(),
example: None,
schema: None,
required: true,
});
let materialized = materialize_import(&plan);
assert!(materialized.imported_operations.is_empty());
assert!(materialized
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "openapi_required_body_without_example"));
}
#[test]
fn materializes_bearer_security_placeholder() {
let mut plan = base_plan();
plan.contract.operations[0].security = vec![vec![ContractSecurityRequirement {
scheme_name: "bearerAuth".to_string(),
kind: ContractSecuritySchemeKind::HttpBearer,
}]];
let materialized = materialize_import(&plan);
assert!(materialized
.source
.contains("* Authorization = Bearer [[ bearerAuth_token ]]"));
}
#[test]
fn materializes_required_cookie_parameter() {
let mut plan = base_plan();
plan.contract.operations[0].parameters.push(ContractParameter {
name: "session".to_string(),
location: "cookie".to_string(),
required: true,
style: "form".to_string(),
explode: true,
allow_reserved: false,
shape: ContractParameterShape::Scalar,
value: None,
});
let materialized = materialize_import(&plan);
assert_eq!(materialized.imported_operations, vec![0]);
assert!(materialized.source.contains("@ session = [[ session ]]"));
}
#[test]
fn materializes_form_urlencoded_example_as_form_fields() {
let mut plan = base_plan();
plan.contract.operations[0].method = "POST".to_string();
plan.contract.operations[0].request_body = Some(ContractRequestBody {
content_type: "application/x-www-form-urlencoded".to_string(),
example: Some(
serde_json::json!({
"name": "Pickles",
"age": 3,
"active": true
})
.to_string(),
),
schema: None,
required: true,
});
let materialized = materialize_import(&plan);
assert!(materialized.source.contains("~ name = Pickles"));
assert!(materialized.source.contains("~ age = 3"));
assert!(materialized.source.contains("~ active = true"));
assert!(!materialized.source.contains("~~~application/x-www-form-urlencoded"));
}
#[test]
fn materializes_required_form_urlencoded_body_from_schema_when_example_is_missing() {
let mut plan = base_plan();
plan.contract.operations[0].method = "POST".to_string();
plan.contract.operations[0].request_body = Some(ContractRequestBody {
content_type: "application/x-www-form-urlencoded".to_string(),
example: None,
schema: Some(ContractResponseSchema::Object {
fields: vec![
ContractSchemaField::required("name", ContractTypeRef::new("string")),
ContractSchemaField::optional("age", ContractTypeRef::new("integer")),
],
}),
required: true,
});
let materialized = materialize_import(&plan);
assert!(materialized.imported_operations.contains(&0));
assert!(materialized.source.contains("~ name = example"));
assert!(!materialized.source.contains("~ age ="));
assert!(materialized.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "openapi_request_body_synthesized"
&& diagnostic.message.contains("application/x-www-form-urlencoded")
}));
}
#[test]
fn materializes_multipart_example_as_form_fields() {
let mut plan = base_plan();
plan.contract.operations[0].method = "POST".to_string();
plan.contract.operations[0].request_body = Some(ContractRequestBody {
content_type: "multipart/form-data".to_string(),
example: Some(
serde_json::json!({
"name": "Pickles",
"age": 3,
"active": true
})
.to_string(),
),
schema: None,
required: true,
});
let materialized = materialize_import(&plan);
assert!(materialized.source.contains("~ name = Pickles"));
assert!(materialized.source.contains("~ age = 3"));
assert!(materialized.source.contains("~ active = true"));
assert!(!materialized.source.contains("~~~multipart/form-data"));
}
#[test]
fn materializes_required_multipart_body_from_schema_when_example_is_missing() {
let mut plan = base_plan();
plan.contract.operations[0].method = "POST".to_string();
plan.contract.operations[0].request_body = Some(ContractRequestBody {
content_type: "multipart/form-data".to_string(),
example: None,
schema: Some(ContractResponseSchema::Object {
fields: vec![
ContractSchemaField::required("name", ContractTypeRef::new("string")),
ContractSchemaField::optional("age", ContractTypeRef::new("integer")),
],
}),
required: true,
});
let materialized = materialize_import(&plan);
assert!(materialized.imported_operations.contains(&0));
assert!(materialized.source.contains("~ name = example"));
assert!(!materialized.source.contains("~ age ="));
assert!(materialized.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "openapi_request_body_synthesized"
&& diagnostic.message.contains("multipart/form-data")
}));
}
#[test]
fn omits_unsupported_multipart_example_shape() {
let mut plan = base_plan();
plan.contract.operations[0].method = "POST".to_string();
plan.contract.operations[0].request_body = Some(ContractRequestBody {
content_type: "multipart/form-data".to_string(),
example: Some("already-encoded-body".to_string()),
schema: None,
required: false,
});
let materialized = materialize_import(&plan);
assert!(materialized.imported_operations.contains(&0));
assert!(!materialized.source.contains("already-encoded-body"));
assert!(materialized.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "openapi_multipart_example_unimplemented"
}));
}
#[test]
fn materializes_raw_form_urlencoded_example_when_not_object_shaped() {
let mut plan = base_plan();
plan.contract.operations[0].method = "POST".to_string();
plan.contract.operations[0].request_body = Some(ContractRequestBody {
content_type: "application/x-www-form-urlencoded".to_string(),
example: Some("name=Pickles&age=3".to_string()),
schema: None,
required: true,
});
let materialized = materialize_import(&plan);
assert!(materialized
.source
.contains("~~~application/x-www-form-urlencoded"));
assert!(materialized.source.contains("name=Pickles&age=3"));
}
#[test]
fn materializes_cookie_security_placeholder() {
let mut plan = base_plan();
plan.contract.operations[0].security = vec![vec![ContractSecurityRequirement {
scheme_name: "sessionCookie".to_string(),
kind: ContractSecuritySchemeKind::ApiKeyCookie {
name: "session".to_string(),
},
}]];
let materialized = materialize_import(&plan);
assert!(materialized.source.contains("@ session = [[ session ]]"));
}
#[test]
fn materializes_operation_level_server_override() {
let mut plan = base_plan();
plan.contract.operations[0].servers = vec!["https://pets.example.com/v2".to_string()];
let materialized = materialize_import(&plan);
assert!(materialized
.source
.contains("GET https://pets.example.com/v2/pets/[[ petId ]]"));
}
#[test]
fn materializes_label_style_path_parameter() {
let mut plan = base_plan();
plan.contract.operations[0].parameters[0].style = "label".to_string();
let materialized = materialize_import(&plan);
assert!(materialized
.source
.contains("GET https://api.example.com/pets/.[[ petId ]]"));
}
#[test]
fn materializes_matrix_style_path_parameter() {
let mut plan = base_plan();
plan.contract.operations[0].parameters[0].style = "matrix".to_string();
let materialized = materialize_import(&plan);
assert!(materialized
.source
.contains("GET https://api.example.com/pets/;petId=[[ petId ]]"));
}
#[test]
fn materializes_parameter_examples_and_defaults() {
let mut plan = base_plan();
plan.contract.operations[0].parameters[0].value = Some("pet-123".to_string());
plan.contract.operations[0].parameters[1].value = Some("trace-123".to_string());
plan.contract.operations[0].parameters.push(ContractParameter {
name: "search".to_string(),
location: "query".to_string(),
required: true,
style: "form".to_string(),
explode: true,
allow_reserved: false,
shape: ContractParameterShape::Scalar,
value: Some("hound".to_string()),
});
plan.contract.operations[0].parameters.push(ContractParameter {
name: "session".to_string(),
location: "cookie".to_string(),
required: true,
style: "form".to_string(),
explode: true,
allow_reserved: false,
shape: ContractParameterShape::Scalar,
value: Some("cookie-123".to_string()),
});
let materialized = materialize_import(&plan);
assert!(materialized
.source
.contains("GET https://api.example.com/pets/pet-123"));
assert!(materialized.source.contains("? search = hound"));
assert!(materialized.source.contains("* trace-id = trace-123"));
assert!(materialized.source.contains("@ session = cookie-123"));
assert!(!materialized.source.contains("[[ petId ]]"));
assert!(!materialized.source.contains("[[ trace_id ]]"));
}
#[test]
fn materializes_form_exploded_query_array_parameter_from_example() {
let mut plan = base_plan();
plan.contract.operations[0].parameters.push(ContractParameter {
name: "tag".to_string(),
location: "query".to_string(),
required: true,
style: "form".to_string(),
explode: true,
allow_reserved: false,
shape: ContractParameterShape::Array,
value: Some(serde_json::json!(["dog", "cat"]).to_string()),
});
let materialized = materialize_import(&plan);
assert!(materialized.source.contains("? tag = dog"));
assert!(materialized.source.contains("? tag = cat"));
}
#[test]
fn materializes_space_and_pipe_delimited_query_arrays() {
let mut plan = base_plan();
plan.contract.operations[0].parameters.push(ContractParameter {
name: "tag".to_string(),
location: "query".to_string(),
required: true,
style: "spaceDelimited".to_string(),
explode: false,
allow_reserved: false,
shape: ContractParameterShape::Array,
value: Some(serde_json::json!(["dog", "cat"]).to_string()),
});
plan.contract.operations[0].parameters.push(ContractParameter {
name: "breed".to_string(),
location: "query".to_string(),
required: true,
style: "pipeDelimited".to_string(),
explode: false,
allow_reserved: false,
shape: ContractParameterShape::Array,
value: Some(serde_json::json!(["pug", "beagle"]).to_string()),
});
let materialized = materialize_import(&plan);
assert!(materialized.source.contains("? tag = dog cat"));
assert!(materialized.source.contains("? breed = pug|beagle"));
}
#[test]
fn materializes_comma_delimited_query_header_cookie_arrays_and_matrix_path_arrays() {
let mut plan = base_plan();
plan.contract.operations[0].parameters[0].style = "matrix".to_string();
plan.contract.operations[0].parameters[0].shape = ContractParameterShape::Array;
plan.contract.operations[0].parameters[0].value = Some(serde_json::json!(["dog", "cat"]).to_string());
plan.contract.operations[0].parameters.push(ContractParameter {
name: "tag".to_string(),
location: "query".to_string(),
required: true,
style: "form".to_string(),
explode: false,
allow_reserved: false,
shape: ContractParameterShape::Array,
value: Some(serde_json::json!(["dog", "cat"]).to_string()),
});
plan.contract.operations[0].parameters.push(ContractParameter {
name: "x-tag".to_string(),
location: "header".to_string(),
required: true,
style: "simple".to_string(),
explode: false,
allow_reserved: false,
shape: ContractParameterShape::Array,
value: Some(serde_json::json!(["dog", "cat"]).to_string()),
});
plan.contract.operations[0].parameters.push(ContractParameter {
name: "session".to_string(),
location: "cookie".to_string(),
required: true,
style: "form".to_string(),
explode: false,
allow_reserved: false,
shape: ContractParameterShape::Array,
value: Some(serde_json::json!(["dog", "cat"]).to_string()),
});
let materialized = materialize_import(&plan);
assert!(materialized
.source
.contains("GET https://api.example.com/pets/;petId=dog,cat"));
assert!(materialized.source.contains("? tag = dog,cat"));
assert!(materialized.source.contains("* x-tag = dog,cat"));
assert!(materialized.source.contains("@ session = dog,cat"));
}
#[test]
fn materializes_optional_supported_parameters_when_concrete_values_exist() {
let mut plan = base_plan();
plan.contract.operations[0].parameters[1].required = false;
plan.contract.operations[0].parameters[1].value = Some("trace-123".to_string());
plan.contract.operations[0].parameters.push(ContractParameter {
name: "search".to_string(),
location: "query".to_string(),
required: false,
style: "form".to_string(),
explode: true,
allow_reserved: false,
shape: ContractParameterShape::Scalar,
value: Some("hound".to_string()),
});
plan.contract.operations[0].parameters.push(ContractParameter {
name: "session".to_string(),
location: "cookie".to_string(),
required: false,
style: "form".to_string(),
explode: true,
allow_reserved: false,
shape: ContractParameterShape::Scalar,
value: Some("cookie-123".to_string()),
});
let materialized = materialize_import(&plan);
assert!(materialized.source.contains("? search = hound"));
assert!(materialized.source.contains("* trace-id = trace-123"));
assert!(materialized.source.contains("@ session = cookie-123"));
assert!(!materialized.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "openapi_optional_query_omitted"
|| diagnostic.code == "openapi_optional_header_omitted"
|| diagnostic.code == "openapi_optional_cookie_omitted"
}));
}
#[test]
fn materializes_deep_object_query_parameter_from_example() {
let mut plan = base_plan();
plan.contract.operations[0].parameters.push(ContractParameter {
name: "filter".to_string(),
location: "query".to_string(),
required: true,
style: "deepObject".to_string(),
explode: true,
allow_reserved: false,
shape: ContractParameterShape::Object,
value: Some(serde_json::json!({"species": "dog", "age": 3}).to_string()),
});
let materialized = materialize_import(&plan);
assert!(materialized.source.contains("? filter[age] = 3"));
assert!(materialized.source.contains("? filter[species] = dog"));
}
#[test]
fn materializes_form_query_object_parameters() {
let mut plan = base_plan();
plan.contract.operations[0].parameters.push(ContractParameter {
name: "filter".to_string(),
location: "query".to_string(),
required: true,
style: "form".to_string(),
explode: true,
allow_reserved: false,
shape: ContractParameterShape::Object,
value: Some(serde_json::json!({"species": "dog", "age": 3}).to_string()),
});
plan.contract.operations[0].parameters.push(ContractParameter {
name: "filterCompact".to_string(),
location: "query".to_string(),
required: true,
style: "form".to_string(),
explode: false,
allow_reserved: false,
shape: ContractParameterShape::Object,
value: Some(serde_json::json!({"species": "dog", "age": 3}).to_string()),
});
let materialized = materialize_import(&plan);
assert!(materialized.source.contains("? age = 3"));
assert!(materialized.source.contains("? species = dog"));
assert!(materialized.source.contains("? filterCompact = age,3,species,dog"));
}
#[test]
fn skips_operation_with_required_unsupported_parameter_serialization() {
let mut plan = base_plan();
plan.contract.operations[0].parameters.push(ContractParameter {
name: "filters".to_string(),
location: "query".to_string(),
required: true,
style: "deepObject".to_string(),
explode: true,
allow_reserved: false,
shape: ContractParameterShape::Object,
value: None,
});
let materialized = materialize_import(&plan);
assert!(materialized.imported_operations.is_empty());
assert!(materialized.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "openapi_required_parameter_unimplemented"
&& diagnostic.message.contains("filters")
}));
}
#[test]
fn omits_optional_unsupported_parameter_serialization() {
let mut plan = base_plan();
plan.contract.operations[0].parameters.push(ContractParameter {
name: "filters".to_string(),
location: "query".to_string(),
required: false,
style: "deepObject".to_string(),
explode: true,
allow_reserved: false,
shape: ContractParameterShape::Object,
value: None,
});
let materialized = materialize_import(&plan);
assert!(materialized.imported_operations.contains(&0));
assert!(!materialized.source.contains("filters"));
assert!(materialized.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "openapi_optional_parameter_unimplemented"
&& diagnostic.message.contains("filters")
}));
}
#[test]
fn materializes_first_supported_security_alternative() {
let mut plan = base_plan();
plan.contract.operations[0].security = vec![
vec![ContractSecurityRequirement {
scheme_name: "bearerAuth".to_string(),
kind: ContractSecuritySchemeKind::HttpBearer,
}],
vec![ContractSecurityRequirement {
scheme_name: "headerKey".to_string(),
kind: ContractSecuritySchemeKind::ApiKeyHeader {
name: "X-API-Key".to_string(),
},
}],
];
let materialized = materialize_import(&plan);
assert!(materialized
.source
.contains("* Authorization = Bearer [[ bearerAuth_token ]]"));
assert!(!materialized.source.contains("* X-API-Key = [[ X_API_Key ]]"));
assert!(materialized.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "openapi_security_alternatives_reduced"
}));
}
#[test]
fn materializes_oauth_client_credentials_profile() {
let mut plan = base_plan();
plan.contract.operations[0].security = vec![vec![ContractSecurityRequirement {
scheme_name: "oauthClient".to_string(),
kind: ContractSecuritySchemeKind::OAuthClientCredentials {
token_url: "https://login.example.com/oauth/token".to_string(),
scopes: vec!["read:pets".to_string(), "write:pets".to_string()],
},
}]];
let materialized = materialize_import(&plan);
assert!(materialized.source.contains("oauth oauthClient"));
assert!(materialized.source.contains("grant = client_credentials"));
assert!(materialized
.source
.contains("token_url = https://login.example.com/oauth/token"));
assert!(materialized
.source
.contains("client_id = secret.env(\"HEN_OPENAPI_OAUTHCLIENT_CLIENT_ID\")"));
assert!(materialized
.source
.contains("client_secret = secret.env(\"HEN_OPENAPI_OAUTHCLIENT_CLIENT_SECRET\")"));
assert!(materialized.source.contains("scope = read:pets write:pets"));
assert!(materialized.source.contains("auth = oauthClient"));
assert!(materialized.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "openapi_oauth_placeholders_generated"
}));
}
#[test]
fn materializes_required_json_body_from_schema_when_example_is_missing() {
let mut plan = base_plan();
plan.contract.operations[0].method = "POST".to_string();
plan.contract.operations[0].request_body = Some(ContractRequestBody {
content_type: "application/json".to_string(),
example: None,
schema: Some(ContractResponseSchema::Object {
fields: vec![
ContractSchemaField::required("name", ContractTypeRef::new("string")),
ContractSchemaField::optional("age", ContractTypeRef::new("integer")),
],
}),
required: true,
});
let materialized = materialize_import(&plan);
assert!(materialized.imported_operations.contains(&0));
assert!(materialized.source.contains("~~~json"));
assert!(materialized.source.contains("\"name\": \"example\""));
assert!(!materialized.source.contains("\"age\""));
assert!(materialized.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "openapi_request_body_synthesized"
}));
}
#[test]
fn materializes_component_schemas_and_response_assertion() {
let mut plan = base_plan();
plan.contract.schemas = vec![
ContractSchemaDefinition {
name: "Pet".to_string(),
shape: ContractSchemaShape::Object {
fields: vec![
ContractSchemaField::required("id", ContractTypeRef::new("integer")),
ContractSchemaField::required("name", ContractTypeRef::new("string")),
ContractSchemaField::optional(
"tag",
ContractTypeRef::new("string").nullable(),
),
],
},
},
ContractSchemaDefinition {
name: "PetList".to_string(),
shape: ContractSchemaShape::Array(ContractTypeRef::new("Pet").array()),
},
];
plan.contract.operations[0].response_body = Some(ContractResponseBody {
content_type: "application/json".to_string(),
schema: Some(ContractResponseSchema::Type(ContractTypeRef::new("PetList"))),
});
let materialized = materialize_import(&plan);
assert!(materialized.source.contains("schema Pet {"));
assert!(materialized.source.contains("tag?: string?"));
assert!(materialized.source.contains("schema PetList = Pet[]"));
assert!(materialized.source.contains("^ & body === PetList"));
}
#[test]
fn materializes_inline_array_response_as_synthetic_schema() {
let mut plan = base_plan();
plan.contract.scalars = vec![ContractScalarDefinition {
name: "PetId".to_string(),
expression: "UUID".to_string(),
}];
plan.contract.operations[0].response_body = Some(ContractResponseBody {
content_type: "application/json".to_string(),
schema: Some(ContractResponseSchema::Type(ContractTypeRef::new("PetId").array())),
});
let materialized = materialize_import(&plan);
assert!(materialized.source.contains("scalar PetId = UUID"));
assert!(materialized.source.contains("schema getPetResponse = PetId[]"));
assert!(materialized.source.contains("^ & body === getPetResponse"));
}
}