use std::collections::HashSet;
use heck::ToPascalCase;
use indexmap::IndexMap;
use crate::spec::oas32::spec::{
self as oas, ObjectOrReference, ObjectSchema, Schema, SchemaType, SchemaTypeSet,
};
use super::LowerError;
use crate::ir::types::{
ApiKeyLocation, IrContact, IrEnum, IrEnumValue, IrEnumValueType, IrHeader, IrInfo,
IrIntersection, IrLicense, IrOAuth2Flow, IrOAuth2Flows, IrObject, IrOperation, IrParameter,
IrPrimitive, IrProperty, IrRequestBody, IrRequestBodyEncoding, IrResponse, IrSchema,
IrSchemaKind, IrSecurityRequirement, IrSecurityScheme, IrServer, IrSpec, IrTaggedUnion,
IrTaggedVariant, IrTypeExpr, IrUnion, IrValidation, ParameterLocation, TaggingStyle,
};
pub fn lower_v32(spec: &oas::OpenApiV32Spec) -> Result<IrSpec, LowerError> {
let mut ctx = LowerCtx::new(spec);
ctx.in_component_phase = true;
if let Some(components) = &spec.components {
for (name, schema_ref) in &components.schemas {
let mut schema = ctx.lower_named_schema(name, schema_ref)?;
schema.is_component = true;
ctx.schemas.insert(name.clone(), schema);
}
}
ctx.in_component_phase = false;
let mut operations = Vec::new();
if let Some(paths) = &spec.paths {
for (path, path_item) in paths {
let ops = ctx.lower_path_item(path, path_item)?;
operations.extend(ops);
}
}
for (name, path_item) in &spec.webhooks {
let ops = ctx.lower_path_item(name, path_item)?;
operations.extend(ops);
}
let mut security_schemes = IndexMap::new();
if let Some(components) = &spec.components {
for (name, scheme_ref) in &components.security_schemes {
if let ObjectOrReference::Object(scheme) = scheme_ref {
security_schemes.insert(name.clone(), lower_security_scheme(scheme));
}
}
}
let security = spec
.security
.iter()
.flat_map(|req| {
req.0.iter().map(|(name, scopes)| IrSecurityRequirement {
scheme_name: name.clone(),
scopes: scopes.clone(),
})
})
.collect();
Ok(IrSpec {
info: lower_info(&spec.info),
servers: spec
.servers
.iter()
.map(|s| IrServer {
url: s.url.clone(),
description: s.description.clone(),
})
.collect(),
schemas: ctx.schemas,
operations,
security_schemes,
security,
})
}
fn server_path_prefix(servers: &[crate::spec::oas32::spec::Server]) -> String {
servers
.first()
.map(|s| {
let u = &s.url;
if let Some(proto_end) = u.find("://") {
let after_proto = &u[proto_end + 3..];
if let Some(path_start) = after_proto.find('/') {
after_proto[path_start..].trim_end_matches('/').to_string()
} else {
String::new()
}
} else if u.starts_with('/') {
u.trim_end_matches('/').to_string()
} else if !u.is_empty() && !u.starts_with("http") {
let with_slash = if u.starts_with('/') {
u.to_string()
} else {
format!("/{u}")
};
with_slash.trim_end_matches('/').to_string()
} else {
String::new()
}
})
.unwrap_or_default()
}
fn strip_server_path_prefix(path: &str, servers: &[crate::spec::oas32::spec::Server]) -> String {
let prefix = server_path_prefix(servers);
if !prefix.is_empty()
&& path.starts_with(&prefix)
&& (path.len() == prefix.len() || path.as_bytes()[prefix.len()] == b'/')
{
let stripped = &path[prefix.len()..];
if stripped.is_empty() { "/" } else { stripped }.to_string()
} else {
path.to_string()
}
}
struct LowerCtx<'a> {
spec: &'a oas::OpenApiV32Spec,
schemas: IndexMap<String, IrSchema>,
used_names: std::collections::HashSet<String>,
in_component_phase: bool,
}
impl<'a> LowerCtx<'a> {
fn new(spec: &'a oas::OpenApiV32Spec) -> Self {
let mut used_names = std::collections::HashSet::new();
if let Some(components) = &spec.components {
for name in components.schemas.keys() {
used_names.insert(name.clone());
}
}
LowerCtx {
spec,
schemas: IndexMap::new(),
used_names,
in_component_phase: false,
}
}
fn generate_unique_name(&mut self, base: &str) -> String {
if self.used_names.insert(base.to_string()) {
return base.to_string();
}
let mut suffix = 2;
loop {
let candidate = format!("{base}{suffix}");
if self.used_names.insert(candidate.clone()) {
return candidate;
}
suffix += 1;
}
}
fn lower_named_schema(
&mut self,
name: &str,
schema_ref: &ObjectOrReference<ObjectSchema>,
) -> Result<IrSchema, LowerError> {
match schema_ref {
ObjectOrReference::Ref { ref_path, .. } => Ok(IrSchema {
name: name.to_string(),
description: None,
deprecated: false,
kind: IrSchemaKind::Alias(IrTypeExpr::Named(extract_schema_name(ref_path))),
is_component: false,
}),
ObjectOrReference::Object(obj) => self.lower_object_schema_to_ir_schema(name, obj),
}
}
fn lower_object_schema_to_ir_schema(
&mut self,
name: &str,
obj: &ObjectSchema,
) -> Result<IrSchema, LowerError> {
let kind = self.classify_schema(name, obj)?;
Ok(IrSchema {
name: name.to_string(),
description: obj.description.clone(),
deprecated: obj.deprecated.unwrap_or(false),
kind,
is_component: false,
})
}
fn classify_schema(
&mut self,
name: &str,
obj: &ObjectSchema,
) -> Result<IrSchemaKind, LowerError> {
if !obj.enum_values.is_empty() {
return Ok(IrSchemaKind::Enum(self.lower_enum(obj)));
}
if !obj.one_of.is_empty() {
if let Some(tagged) = self.try_lower_tagged_union(name, obj)? {
return Ok(IrSchemaKind::TaggedUnion(tagged));
}
let has_null = obj.one_of.iter().any(is_null_schema);
let non_null: Vec<_> = obj.one_of.iter().filter(|m| !is_null_schema(m)).collect();
let members = non_null
.iter()
.enumerate()
.map(|(idx, m)| {
let candidate = format!("{}Member{}", name, idx + 1);
self.lower_schema_ref_with_promotion(&candidate, m)
})
.collect::<Result<Vec<_>, _>>()?;
return Ok(IrSchemaKind::Union(IrUnion {
members,
nullable: has_null,
}));
}
if !obj.any_of.is_empty() {
let has_null = obj.any_of.iter().any(is_null_schema);
let non_null: Vec<_> = obj.any_of.iter().filter(|m| !is_null_schema(m)).collect();
let members = non_null
.iter()
.enumerate()
.map(|(idx, m)| {
let candidate = format!("{}Member{}", name, idx + 1);
self.lower_schema_ref_with_promotion(&candidate, m)
})
.collect::<Result<Vec<_>, _>>()?;
return Ok(IrSchemaKind::Union(IrUnion {
members,
nullable: has_null,
}));
}
if !obj.all_of.is_empty() {
let members = obj
.all_of
.iter()
.enumerate()
.map(|(idx, m)| {
let candidate = format!("{}AllOf{}", name, idx + 1);
self.lower_schema_ref_with_promotion(&candidate, m)
})
.collect::<Result<Vec<_>, _>>()?;
return Ok(IrSchemaKind::Intersection(IrIntersection { members }));
}
if !obj.properties.is_empty() || obj.additional_properties.is_some() {
return Ok(IrSchemaKind::Object(self.lower_object(name, obj)?));
}
if let Some(items) = &obj.items {
let items_candidate = format!("{name}Items");
let inner = self.lower_schema_value_with_promotion(&items_candidate, items)?;
return Ok(IrSchemaKind::Alias(IrTypeExpr::Array(Box::new(inner))));
}
Ok(IrSchemaKind::Alias(self.lower_type_from_schema(obj)))
}
fn lower_enum(&self, obj: &ObjectSchema) -> IrEnum {
let value_type = classify_enum_values(&obj.enum_values);
let values = obj
.enum_values
.iter()
.map(|v| IrEnumValue {
value: v.clone(),
description: None,
})
.collect();
IrEnum { value_type, values }
}
fn try_lower_tagged_union(
&mut self,
parent_name: &str,
obj: &ObjectSchema,
) -> Result<Option<IrTaggedUnion>, LowerError> {
if let Some(disc) = &obj.discriminator {
return self.lower_discriminated_union(parent_name, obj, disc);
}
use crate::ir::tagged_enum_pattern::TaggedEnumPattern;
let patterns: Vec<Option<TaggedEnumPattern>> = obj
.one_of
.iter()
.map(TaggedEnumPattern::detect_from_schema_v32)
.collect();
let all_tagged = patterns.iter().all(|p| p.is_some());
if !all_tagged || patterns.is_empty() {
return Ok(None);
}
let all_external = patterns
.iter()
.all(|p| matches!(p, Some(TaggedEnumPattern::ExternallyTagged { .. })));
if all_external {
let mut variants = Vec::new();
for (member, pattern) in obj.one_of.iter().zip(patterns.iter()) {
let pattern = pattern.as_ref().unwrap();
let (disc_value, content_type) =
self.extract_tagged_variant(parent_name, member, pattern)?;
let description = match member {
ObjectOrReference::Object(obj) => obj.description.clone(),
_ => None,
};
variants.push(IrTaggedVariant {
discriminator_value: disc_value,
content_type,
description,
});
}
return Ok(Some(IrTaggedUnion {
discriminator_field: String::new(), tagging: TaggingStyle::External,
variants,
}));
}
let tag_fields: Vec<&str> = patterns
.iter()
.filter_map(|p| p.as_ref()?.tag_field())
.collect();
if tag_fields.is_empty() {
return Ok(None);
}
let first_tag = tag_fields[0];
let all_same_tag = tag_fields.iter().all(|t| *t == first_tag);
if !all_same_tag {
return Ok(None);
}
let tagging = classify_tagging_style(&patterns);
let mut variants = Vec::new();
for (member, pattern) in obj.one_of.iter().zip(patterns.iter()) {
let pattern = pattern.as_ref().unwrap();
let effective_pattern = match pattern {
TaggedEnumPattern::ExternallyTagged { .. } => TaggedEnumPattern::InternallyTagged {
variant_name: pattern.variant_name().to_string(),
tag_field: first_tag.to_string(),
},
TaggedEnumPattern::AdjacentlyTagged {
variant_name,
tag_field,
..
} if matches!(tagging, TaggingStyle::Internal) => {
TaggedEnumPattern::InternallyTagged {
variant_name: variant_name.clone(),
tag_field: tag_field.clone(),
}
}
other => other.clone(),
};
let (disc_value, content_type) =
self.extract_tagged_variant(parent_name, member, &effective_pattern)?;
let description = match member {
ObjectOrReference::Object(obj) => obj.description.clone(),
_ => None,
};
variants.push(IrTaggedVariant {
discriminator_value: disc_value,
content_type,
description,
});
}
Ok(Some(IrTaggedUnion {
discriminator_field: first_tag.to_string(),
tagging,
variants,
}))
}
fn lower_discriminated_union(
&mut self,
parent_name: &str,
obj: &ObjectSchema,
disc: &oas::Discriminator,
) -> Result<Option<IrTaggedUnion>, LowerError> {
let mut variants = Vec::new();
if let Some(mapping) = &disc.mapping {
for (value, ref_path) in mapping {
let schema_name = extract_schema_name(ref_path);
variants.push(IrTaggedVariant {
discriminator_value: value.clone(),
content_type: IrTypeExpr::Named(schema_name),
description: None,
});
}
} else {
for member in &obj.one_of {
match member {
ObjectOrReference::Ref { ref_path, .. } => {
let schema_name = extract_schema_name(ref_path);
variants.push(IrTaggedVariant {
discriminator_value: schema_name.clone(),
content_type: IrTypeExpr::Named(schema_name),
description: None,
});
}
ObjectOrReference::Object(inline_obj) => {
let variant_name =
self.generate_unique_name(&format!("{parent_name}Variant"));
let schema =
self.lower_object_schema_to_ir_schema(&variant_name, inline_obj)?;
let disc_value = variant_name.clone();
self.schemas.insert(variant_name.clone(), schema);
variants.push(IrTaggedVariant {
discriminator_value: disc_value,
content_type: IrTypeExpr::Named(variant_name),
description: None,
});
}
}
}
}
Ok(Some(IrTaggedUnion {
discriminator_field: disc.property_name.clone().unwrap_or_default(),
tagging: TaggingStyle::Internal,
variants,
}))
}
fn extract_tagged_variant(
&mut self,
parent_name: &str,
member: &ObjectOrReference<ObjectSchema>,
pattern: &crate::ir::tagged_enum_pattern::TaggedEnumPattern,
) -> Result<(String, IrTypeExpr), LowerError> {
use crate::ir::tagged_enum_pattern::TaggedEnumPattern;
match pattern {
TaggedEnumPattern::InternallyTagged { tag_field, .. } => {
if let ObjectOrReference::Object(obj) = member {
if !obj.all_of.is_empty() {
let mut content_ref = None;
let mut disc_value = None;
for item in &obj.all_of {
match item {
ObjectOrReference::Ref { ref_path, .. } => {
content_ref = Some(extract_schema_name(ref_path));
}
ObjectOrReference::Object(tag_obj) => {
if let Some(ObjectOrReference::Object(prop_schema)) =
tag_obj.properties.get(tag_field.as_str())
&& let Some(first_enum) = prop_schema.enum_values.first()
{
disc_value = first_enum.as_str().map(String::from);
}
}
}
}
let content_type = content_ref
.map(IrTypeExpr::Named)
.unwrap_or(IrTypeExpr::Any);
let disc_value =
disc_value.unwrap_or_else(|| pattern.variant_name().to_string());
Ok((disc_value, content_type))
} else {
let disc_value = obj
.properties
.get(tag_field.as_str())
.and_then(|prop_ref| match prop_ref {
ObjectOrReference::Object(prop_obj) => prop_obj
.enum_values
.first()
.and_then(|v| v.as_str().map(String::from)),
_ => None,
})
.unwrap_or_else(|| pattern.variant_name().to_string());
let variant_name = self.generate_unique_name(&format!(
"{}{}",
parent_name,
disc_value.to_pascal_case()
));
let mut variant_schema =
self.lower_object_schema_to_ir_schema(&variant_name, obj)?;
variant_schema.is_component = self.in_component_phase;
self.schemas.insert(variant_name.clone(), variant_schema);
Ok((disc_value, IrTypeExpr::Named(variant_name)))
}
} else {
Ok((
pattern.variant_name().to_string(),
self.lower_schema_ref(member)?,
))
}
}
TaggedEnumPattern::ExternallyTagged { .. } => {
if let ObjectOrReference::Object(obj) = member {
if let Some((prop_name, prop_schema)) = obj.properties.iter().next() {
let content_type = self.lower_schema_ref_with_promotion(
&format!("{parent_name}{}", prop_name.to_pascal_case()),
prop_schema,
)?;
Ok((prop_name.clone(), content_type))
} else {
Ok((pattern.variant_name().to_string(), IrTypeExpr::Any))
}
} else {
Ok((
pattern.variant_name().to_string(),
self.lower_schema_ref(member)?,
))
}
}
TaggedEnumPattern::AdjacentlyTagged { content_field, .. } => {
if let ObjectOrReference::Object(obj) = member {
let tag_field = pattern.tag_field().unwrap_or("");
let disc_value = obj
.properties
.get(tag_field)
.and_then(|prop| {
if let ObjectOrReference::Object(prop_obj) = prop {
prop_obj.enum_values.first()?.as_str().map(String::from)
} else {
None
}
})
.unwrap_or_else(|| pattern.variant_name().to_string());
let content_type = obj
.properties
.get(content_field.as_str())
.map(|prop| {
self.lower_schema_ref_with_promotion(
&format!(
"{}{}{}",
parent_name,
disc_value.to_pascal_case(),
content_field.to_pascal_case()
),
prop,
)
})
.transpose()?
.unwrap_or(IrTypeExpr::Any);
Ok((disc_value, content_type))
} else {
Ok((
pattern.variant_name().to_string(),
self.lower_schema_ref(member)?,
))
}
}
TaggedEnumPattern::Untagged { .. } => Ok((
pattern.variant_name().to_string(),
self.lower_schema_ref(member)?,
)),
}
}
fn lower_object(
&mut self,
parent_name: &str,
obj: &ObjectSchema,
) -> Result<IrObject, LowerError> {
let required_set: std::collections::HashSet<&str> =
obj.required.iter().map(|s| s.as_str()).collect();
let mut properties = IndexMap::new();
for (field_name, prop_ref) in &obj.properties {
let type_expr = self.lower_schema_ref_with_promotion(
&format!("{parent_name}{}", field_name.to_pascal_case()),
prop_ref,
)?;
let (base_type, nullable) = unwrap_nullable(type_expr);
let (description, format, default_value, validation) = match prop_ref {
ObjectOrReference::Object(prop_obj) => (
prop_obj.description.clone(),
prop_obj.format.clone(),
prop_obj.default.clone(),
extract_validation(prop_obj),
),
ObjectOrReference::Ref { .. } => (None, None, None, None),
};
properties.insert(
field_name.clone(),
IrProperty {
name: field_name.clone(),
type_expr: base_type,
required: required_set.contains(field_name.as_str()),
nullable,
description,
default_value,
format,
validation,
},
);
}
let additional_properties = match &obj.additional_properties {
Some(Schema::Boolean(b)) => {
if b.0 {
Some(IrTypeExpr::Any)
} else {
None
}
}
Some(Schema::Object(boxed_ref)) => Some(self.lower_schema_ref(boxed_ref)?),
None => None,
};
Ok(IrObject {
properties,
additional_properties,
})
}
fn lower_schema_ref(
&mut self,
schema_ref: &ObjectOrReference<ObjectSchema>,
) -> Result<IrTypeExpr, LowerError> {
match schema_ref {
ObjectOrReference::Ref { ref_path, .. } => {
if ref_path.starts_with("http://") || ref_path.starts_with("https://") {
return Err(LowerError::ExternalReference {
reference: ref_path.clone(),
});
}
Ok(IrTypeExpr::Named(extract_schema_name(ref_path)))
}
ObjectOrReference::Object(obj) => Ok(self.lower_type_from_schema(obj)),
}
}
fn lower_schema_ref_with_promotion(
&mut self,
candidate_name: &str,
schema_ref: &ObjectOrReference<ObjectSchema>,
) -> Result<IrTypeExpr, LowerError> {
match schema_ref {
ObjectOrReference::Ref { ref_path, .. } => {
Ok(IrTypeExpr::Named(extract_schema_name(ref_path)))
}
ObjectOrReference::Object(obj) => {
if let Some(inner) = self.try_lower_nullable_wrapper(candidate_name, obj)? {
return Ok(inner);
}
if should_promote_to_named(obj) {
let name = self.generate_unique_name(candidate_name);
let mut schema = self.lower_object_schema_to_ir_schema(&name, obj)?;
schema.is_component = self.in_component_phase;
self.schemas.insert(name.clone(), schema);
Ok(IrTypeExpr::Named(name))
} else if is_array_with_promotable_items(obj) {
let items_name = format!("{candidate_name}Item");
let inner = self.lower_array_items_with_promotion(&items_name, obj)?;
Ok(IrTypeExpr::Array(Box::new(inner)))
} else {
Ok(self.lower_type_from_schema(obj))
}
}
}
}
fn try_lower_nullable_wrapper(
&mut self,
candidate_name: &str,
obj: &ObjectSchema,
) -> Result<Option<IrTypeExpr>, LowerError> {
let members = if !obj.one_of.is_empty() {
&obj.one_of
} else if !obj.any_of.is_empty() {
&obj.any_of
} else {
return Ok(None);
};
let mut null_count = 0;
let mut non_null: Vec<&ObjectOrReference<ObjectSchema>> = Vec::new();
for m in members {
if is_null_schema(m) {
null_count += 1;
} else {
non_null.push(m);
}
}
if null_count != 1 || non_null.len() != 1 {
return Ok(None);
}
let inner_ref = non_null[0];
let inner = self.lower_schema_ref_with_promotion(candidate_name, inner_ref)?;
Ok(Some(IrTypeExpr::Nullable(Box::new(inner))))
}
fn lower_array_items_with_promotion(
&mut self,
candidate_name: &str,
obj: &ObjectSchema,
) -> Result<IrTypeExpr, LowerError> {
if let Some(items) = &obj.items {
match items.as_ref() {
Schema::Object(boxed_ref) => {
self.lower_schema_ref_with_promotion(candidate_name, boxed_ref)
}
Schema::Boolean(_) => Ok(IrTypeExpr::Any),
}
} else {
Ok(IrTypeExpr::Any)
}
}
fn lower_type_from_schema(&self, obj: &ObjectSchema) -> IrTypeExpr {
if obj.enum_values.len() == 1
&& let Some(serde_json::Value::String(s)) = obj.enum_values.first()
{
return IrTypeExpr::StringLiteral(s.clone());
}
if obj.enum_values.len() > 1 {
let all_strings: Vec<String> = obj
.enum_values
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
if all_strings.len() == obj.enum_values.len() {
return IrTypeExpr::StringEnum(all_strings);
}
}
match &obj.schema_type {
Some(type_set) => type_set_to_type_expr(type_set, obj),
None => {
if !obj.properties.is_empty() {
IrTypeExpr::Any } else if !obj.enum_values.is_empty() {
IrTypeExpr::Primitive(IrPrimitive::String) } else {
IrTypeExpr::Any
}
}
}
}
fn lower_schema_value_with_promotion(
&mut self,
candidate_name: &str,
schema: &Schema,
) -> Result<IrTypeExpr, LowerError> {
match schema {
Schema::Boolean(_) => Ok(IrTypeExpr::Any),
Schema::Object(boxed_ref) => {
self.lower_schema_ref_with_promotion(candidate_name, boxed_ref)
}
}
}
fn lower_path_item(
&mut self,
path: &str,
item: &oas::PathItem,
) -> Result<Vec<IrOperation>, LowerError> {
let mut ops = Vec::new();
let path_params = item
.parameters
.iter()
.map(|p| self.lower_parameter_ref(p))
.collect::<Result<Vec<_>, _>>()?;
macro_rules! lower_method {
($field:ident, $method:expr) => {
if let Some(op) = &item.$field {
ops.push(self.lower_operation(path, $method, op, &path_params)?);
}
};
}
lower_method!(get, "GET");
lower_method!(put, "PUT");
lower_method!(post, "POST");
lower_method!(delete, "DELETE");
lower_method!(options, "OPTIONS");
lower_method!(head, "HEAD");
lower_method!(patch, "PATCH");
lower_method!(trace, "TRACE");
lower_method!(query, "QUERY");
Ok(ops)
}
fn lower_operation(
&mut self,
path: &str,
method: &str,
op: &oas::Operation,
path_params: &[IrParameter],
) -> Result<IrOperation, LowerError> {
let op_id = op
.operation_id
.clone()
.unwrap_or_else(|| format!("{}{}", method.to_lowercase(), path.to_pascal_case()));
let op_parameters = op
.parameters
.iter()
.map(|p| self.lower_parameter_ref(p))
.collect::<Result<Vec<_>, _>>()?;
let mut parameters = Vec::new();
for pp in path_params {
let overridden = op_parameters
.iter()
.any(|op| op.name == pp.name && op.location == pp.location);
if !overridden {
parameters.push(pp.clone());
}
}
parameters.extend(op_parameters);
let request_body = match &op.request_body {
Some(ObjectOrReference::Object(rb)) => Some(self.lower_request_body(&op_id, rb)?),
Some(ObjectOrReference::Ref { ref_path, .. }) => {
self.lower_request_body_ref(&op_id, ref_path)?
}
None => None,
};
let responses = match &op.responses {
Some(resp_map) => resp_map
.iter()
.map(|(status, resp_ref)| self.lower_response_ref(&op_id, status, resp_ref))
.collect::<Result<Vec<_>, _>>()?,
None => Vec::new(),
};
let security = op
.security
.iter()
.flat_map(|req| {
req.0.iter().map(|(name, scopes)| IrSecurityRequirement {
scheme_name: name.clone(),
scopes: scopes.clone(),
})
})
.collect();
Ok(IrOperation {
operation_id: op_id,
tags: op.tags.clone(),
method: method.to_string(),
path: strip_server_path_prefix(path, &self.spec.servers),
summary: op.summary.clone(),
description: op.description.clone(),
deprecated: op.deprecated.unwrap_or(false),
parameters,
request_body,
responses,
security,
})
}
fn lower_parameter_ref(
&mut self,
param_ref: &ObjectOrReference<oas::Parameter>,
) -> Result<IrParameter, LowerError> {
match param_ref {
ObjectOrReference::Object(param) => self.lower_parameter(param),
ObjectOrReference::Ref { ref_path, .. } => {
if let Some(components) = &self.spec.components {
let name = extract_component_name(ref_path);
if let Some(ObjectOrReference::Object(param)) = components.parameters.get(&name)
{
return self.lower_parameter(param);
}
}
Err(LowerError::UnresolvedReference {
reference: ref_path.clone(),
})
}
}
}
fn lower_parameter(&mut self, param: &oas::Parameter) -> Result<IrParameter, LowerError> {
let type_expr = match ¶m.schema {
Some(schema_ref) => self.lower_schema_ref(schema_ref)?,
None => IrTypeExpr::Any,
};
Ok(IrParameter {
name: param.name.clone(),
location: match param.location {
oas::ParameterIn::Path => ParameterLocation::Path,
oas::ParameterIn::Query => ParameterLocation::Query,
oas::ParameterIn::Header => ParameterLocation::Header,
oas::ParameterIn::Cookie => ParameterLocation::Cookie,
oas::ParameterIn::Querystring => ParameterLocation::Query,
},
type_expr,
required: param.required.unwrap_or(false),
description: param.description.clone(),
default_value: param.schema.as_ref().and_then(|s| match s {
ObjectOrReference::Object(obj) => obj.default.clone(),
_ => None,
}),
})
}
fn lower_request_body(
&mut self,
parent_name: &str,
rb: &oas::RequestBody,
) -> Result<IrRequestBody, LowerError> {
let mut content = IndexMap::new();
let mut encoding = IndexMap::new();
for (mime, media_type) in &rb.content {
if let Some(schema_ref) = &media_type.schema {
let type_expr = self.lower_schema_ref_with_promotion(
&format!("{parent_name}Request"),
schema_ref,
)?;
content.insert(mime.clone(), type_expr);
}
let mut media_encoding = IndexMap::new();
for (name, enc) in &media_type.encoding {
if enc.content_type.is_some() {
media_encoding.insert(
name.clone(),
IrRequestBodyEncoding {
content_type: enc.content_type.clone(),
},
);
}
}
if !media_encoding.is_empty() {
encoding.insert(mime.clone(), media_encoding);
}
}
Ok(IrRequestBody {
required: rb.required.unwrap_or(false),
description: rb.description.clone(),
content,
encoding,
})
}
fn lower_request_body_ref(
&mut self,
parent_name: &str,
ref_path: &str,
) -> Result<Option<IrRequestBody>, LowerError> {
if let Some(components) = &self.spec.components {
let name = extract_component_name(ref_path);
if let Some(ObjectOrReference::Object(rb)) = components.request_bodies.get(&name) {
return Ok(Some(self.lower_request_body(parent_name, rb)?));
}
}
Err(LowerError::UnresolvedReference {
reference: ref_path.to_string(),
})
}
fn lower_response_ref(
&mut self,
parent_name: &str,
status: &str,
resp_ref: &ObjectOrReference<oas::Response>,
) -> Result<IrResponse, LowerError> {
match resp_ref {
ObjectOrReference::Object(resp) => self.lower_response(parent_name, status, resp),
ObjectOrReference::Ref { ref_path, .. } => {
if let Some(components) = &self.spec.components {
let name = extract_component_name(ref_path);
if let Some(ObjectOrReference::Object(resp)) = components.responses.get(&name) {
return self.lower_response(parent_name, status, resp);
}
}
Err(LowerError::UnresolvedReference {
reference: ref_path.clone(),
})
}
}
}
fn lower_response(
&mut self,
parent_name: &str,
status: &str,
resp: &oas::Response,
) -> Result<IrResponse, LowerError> {
let mut content = IndexMap::new();
let mut item_content = IndexMap::new();
for (mime, media_type) in &resp.content {
if let Some(schema_ref) = &media_type.schema {
let type_expr = self.lower_schema_ref_with_promotion(
&format!("{parent_name}Response{status}"),
schema_ref,
)?;
content.insert(mime.clone(), type_expr);
}
if let Some(item_schema_ref) = &media_type.item_schema {
let type_expr = self.lower_schema_ref_with_promotion(
&format!("{parent_name}Response{status}Item"),
item_schema_ref,
)?;
item_content.insert(mime.clone(), type_expr);
}
}
let mut headers = IndexMap::new();
for (name, header_ref) in &resp.headers {
headers.insert(name.clone(), self.lower_response_header_ref(header_ref)?);
}
Ok(IrResponse {
status: status.to_string(),
description: resp.description.clone().unwrap_or_default(),
content,
item_content,
headers,
})
}
fn lower_response_header_ref(
&mut self,
header_ref: &ObjectOrReference<oas::Header>,
) -> Result<IrHeader, LowerError> {
self.lower_response_header_ref_inner(header_ref, &mut HashSet::new())
}
fn lower_response_header_ref_inner(
&mut self,
header_ref: &ObjectOrReference<oas::Header>,
visited: &mut HashSet<String>,
) -> Result<IrHeader, LowerError> {
match header_ref {
ObjectOrReference::Object(header) => self.lower_response_header(header),
ObjectOrReference::Ref { ref_path, .. } => {
if !visited.insert(ref_path.clone()) {
return Err(LowerError::Other {
message: format!("cyclic response header reference: {ref_path}"),
});
}
if let Some(components) = &self.spec.components {
let name = extract_component_name(ref_path);
if let Some(header_ref) = components.headers.get(&name) {
return self.lower_response_header_ref_inner(header_ref, visited);
}
}
Err(LowerError::UnresolvedReference {
reference: ref_path.clone(),
})
}
}
}
fn lower_response_header(&mut self, header: &oas::Header) -> Result<IrHeader, LowerError> {
let type_expr = match &header.schema {
Some(schema_ref) => self.lower_schema_ref(schema_ref)?,
None => IrTypeExpr::Any,
};
Ok(IrHeader {
description: header.description.clone(),
type_expr,
required: header.required.unwrap_or(false),
})
}
}
fn extract_schema_name(ref_path: &str) -> String {
ref_path
.strip_prefix("#/components/schemas/")
.unwrap_or(ref_path)
.to_string()
}
fn extract_component_name(ref_path: &str) -> String {
ref_path.rsplit('/').next().unwrap_or(ref_path).to_string()
}
fn type_set_to_type_expr(type_set: &SchemaTypeSet, obj: &ObjectSchema) -> IrTypeExpr {
let types: Vec<SchemaType> = match type_set {
SchemaTypeSet::Single(t) => vec![*t],
SchemaTypeSet::Multiple(ts) => ts.clone(),
};
let non_null: Vec<SchemaType> = types
.iter()
.copied()
.filter(|t| *t != SchemaType::Null)
.collect();
let has_null = types.contains(&SchemaType::Null);
if non_null.is_empty() {
return IrTypeExpr::Primitive(IrPrimitive::String); }
let base = if non_null.len() == 1 {
single_type_to_expr(non_null[0], obj)
} else {
let members: Vec<IrTypeExpr> = non_null
.iter()
.map(|t| single_type_to_expr(*t, obj))
.collect();
IrTypeExpr::Union(members)
};
if has_null {
IrTypeExpr::Nullable(Box::new(base))
} else {
base
}
}
fn single_type_to_expr(t: SchemaType, obj: &ObjectSchema) -> IrTypeExpr {
match t {
SchemaType::String => match obj.format.as_deref() {
Some("date") => IrTypeExpr::Primitive(IrPrimitive::Date),
Some("date-time") => IrTypeExpr::Primitive(IrPrimitive::DateTime),
Some("uuid") => IrTypeExpr::Primitive(IrPrimitive::Uuid),
Some("binary") => IrTypeExpr::Primitive(IrPrimitive::Binary),
Some(fmt) => IrTypeExpr::Primitive(IrPrimitive::StringWithFormat(fmt.to_string())),
None => IrTypeExpr::Primitive(IrPrimitive::String),
},
SchemaType::Integer => match obj.format.as_deref() {
Some(fmt) => IrTypeExpr::Primitive(IrPrimitive::IntegerWithFormat(fmt.to_string())),
None => IrTypeExpr::Primitive(IrPrimitive::Integer),
},
SchemaType::Number => match obj.format.as_deref() {
Some(fmt) => IrTypeExpr::Primitive(IrPrimitive::NumberWithFormat(fmt.to_string())),
None => IrTypeExpr::Primitive(IrPrimitive::Number),
},
SchemaType::Boolean => IrTypeExpr::Primitive(IrPrimitive::Boolean),
SchemaType::Array => {
if let Some(items) = &obj.items {
match items.as_ref() {
Schema::Object(boxed_ref) => match boxed_ref.as_ref() {
ObjectOrReference::Ref { ref_path, .. } => IrTypeExpr::Array(Box::new(
IrTypeExpr::Named(extract_schema_name(ref_path)),
)),
ObjectOrReference::Object(inner) => {
let inner_type = single_type_to_expr(
inner
.schema_type
.as_ref()
.map(|ts| match ts {
oas::SchemaTypeSet::Single(t) => *t,
oas::SchemaTypeSet::Multiple(ts) => ts
.iter()
.copied()
.find(|t| *t != oas::SchemaType::Null)
.unwrap_or(oas::SchemaType::String),
})
.unwrap_or(oas::SchemaType::String),
inner,
);
IrTypeExpr::Array(Box::new(inner_type))
}
},
Schema::Boolean(_) => IrTypeExpr::Array(Box::new(IrTypeExpr::Any)),
}
} else {
IrTypeExpr::Array(Box::new(IrTypeExpr::Any))
}
}
SchemaType::Object => {
if let Some(ap) = &obj.additional_properties {
match ap {
Schema::Boolean(b) => {
if b.0 {
IrTypeExpr::Map(Box::new(IrTypeExpr::Any))
} else {
IrTypeExpr::Any
}
}
Schema::Object(boxed_ref) => match boxed_ref.as_ref() {
ObjectOrReference::Ref { ref_path, .. } => IrTypeExpr::Map(Box::new(
IrTypeExpr::Named(extract_schema_name(ref_path)),
)),
ObjectOrReference::Object(inner) => {
let inner_type = single_type_to_expr(
inner
.schema_type
.as_ref()
.map(|ts| match ts {
oas::SchemaTypeSet::Single(t) => *t,
oas::SchemaTypeSet::Multiple(ts) => ts
.iter()
.copied()
.find(|t| *t != oas::SchemaType::Null)
.unwrap_or(oas::SchemaType::String),
})
.unwrap_or(oas::SchemaType::String),
inner,
);
IrTypeExpr::Map(Box::new(inner_type))
}
},
}
} else {
IrTypeExpr::Any
}
}
SchemaType::Null => IrTypeExpr::Primitive(IrPrimitive::String), }
}
fn classify_enum_values(values: &[serde_json::Value]) -> IrEnumValueType {
let mut has_string = false;
let mut has_number = false;
let mut has_integer = false;
for v in values {
match v {
serde_json::Value::String(_) => has_string = true,
serde_json::Value::Number(n) => {
if n.is_i64() || n.is_u64() {
has_integer = true;
} else {
has_number = true;
}
}
_ => {}
}
}
let mixed_count = [has_string, has_number, has_integer]
.iter()
.filter(|&&b| b)
.count();
if mixed_count >= 2 {
IrEnumValueType::Mixed
} else if has_number {
IrEnumValueType::Number
} else if has_integer {
IrEnumValueType::Integer
} else {
IrEnumValueType::String
}
}
fn should_promote_to_named(obj: &ObjectSchema) -> bool {
!obj.properties.is_empty()
|| !obj.all_of.is_empty()
|| !obj.any_of.is_empty()
|| !obj.one_of.is_empty()
}
fn is_null_schema(schema_ref: &ObjectOrReference<ObjectSchema>) -> bool {
match schema_ref {
ObjectOrReference::Object(obj) => {
matches!(
&obj.schema_type,
Some(oas::SchemaTypeSet::Single(oas::SchemaType::Null))
)
}
ObjectOrReference::Ref { .. } => false,
}
}
fn is_array_with_promotable_items(obj: &ObjectSchema) -> bool {
let is_array = obj
.schema_type
.as_ref()
.is_some_and(|t| matches!(t, oas::SchemaTypeSet::Single(oas::SchemaType::Array)));
if !is_array {
return false;
}
if let Some(items) = &obj.items
&& let Schema::Object(boxed_ref) = items.as_ref()
&& let ObjectOrReference::Object(inner) = boxed_ref.as_ref()
{
return should_promote_to_named(inner);
}
false
}
fn classify_tagging_style(
patterns: &[Option<crate::ir::tagged_enum_pattern::TaggedEnumPattern>],
) -> TaggingStyle {
use crate::ir::tagged_enum_pattern::TaggedEnumPattern;
let mut saw_internal = false;
let mut saw_adjacent: Option<String> = None;
let mut adjacent_consistent = true;
for p in patterns.iter().flatten() {
match p {
TaggedEnumPattern::AdjacentlyTagged { content_field, .. } => {
if let Some(existing) = &saw_adjacent {
adjacent_consistent &= existing == content_field;
} else {
saw_adjacent = Some(content_field.clone());
}
}
TaggedEnumPattern::InternallyTagged { .. } => {
saw_internal = true;
}
TaggedEnumPattern::ExternallyTagged { .. } | TaggedEnumPattern::Untagged { .. } => {}
}
}
if let Some(content_field) = saw_adjacent
&& adjacent_consistent
&& !saw_internal
{
return TaggingStyle::Adjacent { content_field };
}
if saw_internal {
return TaggingStyle::Internal;
}
TaggingStyle::Internal
}
fn unwrap_nullable(expr: IrTypeExpr) -> (IrTypeExpr, bool) {
match expr {
IrTypeExpr::Nullable(inner) => (*inner, true),
other => (other, false),
}
}
fn extract_validation(obj: &ObjectSchema) -> Option<IrValidation> {
let v = IrValidation {
max_length: obj.max_length,
min_length: obj.min_length,
pattern: obj.pattern.clone(),
maximum: obj.maximum.as_ref().and_then(|n| n.as_f64()),
exclusive_maximum: obj.exclusive_maximum.as_ref().map(|_| true),
minimum: obj.minimum.as_ref().and_then(|n| n.as_f64()),
exclusive_minimum: obj.exclusive_minimum.as_ref().map(|_| true),
multiple_of: obj.multiple_of.as_ref().and_then(|n| n.as_f64()),
max_items: obj.max_items,
min_items: obj.min_items,
unique_items: obj.unique_items,
};
if v == IrValidation::default() {
None
} else {
Some(v)
}
}
fn lower_info(info: &oas::Info) -> IrInfo {
IrInfo {
title: info.title.clone(),
description: info.description.clone(),
version: info.version.clone(),
terms_of_service: info.terms_of_service.as_ref().map(|u| u.to_string()),
contact: info.contact.as_ref().map(|c| IrContact {
name: c.name.clone(),
url: c.url.as_ref().map(|u| u.to_string()),
email: c.email.clone(),
}),
license: info.license.as_ref().map(|l| IrLicense {
name: l.name.clone(),
url: l.url.as_ref().map(|u| u.to_string()),
identifier: l.identifier.clone(),
}),
}
}
fn lower_security_scheme(scheme: &oas::SecurityScheme) -> IrSecurityScheme {
use oas::SecurityScheme as SS;
match scheme {
SS::ApiKey {
name,
location,
description,
..
} => IrSecurityScheme::ApiKey {
name: name.clone(),
location: match location.as_str() {
"query" => ApiKeyLocation::Query,
"header" => ApiKeyLocation::Header,
"cookie" => ApiKeyLocation::Cookie,
_ => ApiKeyLocation::Header, },
description: description.clone(),
},
SS::Http {
scheme,
bearer_format,
description,
..
} => IrSecurityScheme::Http {
scheme: scheme.clone(),
bearer_format: bearer_format.clone(),
description: description.clone(),
},
SS::OAuth2 {
flows, description, ..
} => IrSecurityScheme::OAuth2 {
flows: Box::new(lower_oauth2_flows(flows)),
description: description.clone(),
},
SS::OpenIdConnect {
open_id_connect_url,
description,
..
} => IrSecurityScheme::OpenIdConnect {
open_id_connect_url: open_id_connect_url.clone(),
description: description.clone(),
},
SS::MutualTls { description, .. } => IrSecurityScheme::MutualTls {
description: description.clone(),
},
}
}
fn lower_oauth2_flows(flows: &oas::Flows) -> IrOAuth2Flows {
IrOAuth2Flows {
implicit: flows.implicit.as_ref().map(|f| IrOAuth2Flow {
authorization_url: Some(f.authorization_url.to_string()),
token_url: None,
refresh_url: f.refresh_url.as_ref().map(|u| u.to_string()),
scopes: f
.scopes
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
}),
password: flows.password.as_ref().map(|f| IrOAuth2Flow {
authorization_url: None,
token_url: Some(f.token_url.to_string()),
refresh_url: f.refresh_url.as_ref().map(|u| u.to_string()),
scopes: f
.scopes
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
}),
client_credentials: flows.client_credentials.as_ref().map(|f| IrOAuth2Flow {
authorization_url: None,
token_url: Some(f.token_url.to_string()),
refresh_url: f.refresh_url.as_ref().map(|u| u.to_string()),
scopes: f
.scopes
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
}),
authorization_code: flows.authorization_code.as_ref().map(|f| IrOAuth2Flow {
authorization_url: Some(f.authorization_url.to_string()),
token_url: Some(f.token_url.to_string()),
refresh_url: f.refresh_url.as_ref().map(|u| u.to_string()),
scopes: f
.scopes
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lower_yaml(yaml: &str) -> IrSpec {
let parsed = crate::parser::parse_content_yaml(yaml).unwrap();
lower_v32(parsed.as_v32().unwrap()).unwrap()
}
#[test]
fn test_lower_minimal_spec() {
let ir = lower_yaml(
r#"
openapi: "3.2.0"
info:
title: Test
version: "1.0"
paths: {}
"#,
);
assert_eq!(ir.info.title, "Test");
assert_eq!(ir.info.version, "1.0");
assert!(ir.schemas.is_empty());
assert!(ir.operations.is_empty());
}
#[test]
fn test_lower_simple_object_schema() {
let ir = lower_yaml(
r#"
openapi: "3.2.0"
info:
title: Test
version: "1.0"
components:
schemas:
User:
type: object
required:
- name
properties:
name:
type: string
age:
type: integer
"#,
);
assert_eq!(ir.schemas.len(), 1);
let user = &ir.schemas["User"];
assert_eq!(user.name, "User");
if let IrSchemaKind::Object(obj) = &user.kind {
assert_eq!(obj.properties.len(), 2);
assert!(obj.properties["name"].required);
assert!(!obj.properties["age"].required);
} else {
panic!("Expected Object schema");
}
}
#[test]
fn test_lower_string_enum() {
let ir = lower_yaml(
r#"
openapi: "3.2.0"
info:
title: Test
version: "1.0"
components:
schemas:
Status:
type: string
enum:
- active
- inactive
"#,
);
let status = &ir.schemas["Status"];
if let IrSchemaKind::Enum(e) = &status.kind {
assert_eq!(e.value_type, IrEnumValueType::String);
assert_eq!(e.values.len(), 2);
} else {
panic!("Expected Enum schema");
}
}
#[test]
fn test_lower_nullable_type() {
let ir = lower_yaml(
r#"
openapi: "3.2.0"
info:
title: Test
version: "1.0"
components:
schemas:
MaybeName:
type:
- string
- "null"
"#,
);
let maybe = &ir.schemas["MaybeName"];
if let IrSchemaKind::Alias(IrTypeExpr::Nullable(inner)) = &maybe.kind {
assert_eq!(**inner, IrTypeExpr::Primitive(IrPrimitive::String));
} else {
panic!("Expected Nullable alias, got {:?}", maybe.kind);
}
}
#[test]
fn test_lower_operation() {
let ir = lower_yaml(
r#"
openapi: "3.2.0"
info:
title: Test
version: "1.0"
paths:
/users:
get:
operationId: listUsers
parameters:
- name: limit
in: query
schema:
type: integer
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
type: string
"#,
);
assert_eq!(ir.operations.len(), 1);
let op = &ir.operations[0];
assert_eq!(op.operation_id, "listUsers");
assert_eq!(op.method, "GET");
assert_eq!(op.path, "/users");
assert_eq!(op.parameters.len(), 1);
assert_eq!(op.parameters[0].name, "limit");
assert_eq!(op.responses.len(), 1);
assert_eq!(op.responses[0].status, "200");
}
#[test]
fn test_lower_inline_and_referenced_response_headers() {
let ir = lower_yaml(
r##"
openapi: "3.2.0"
info:
title: Test
version: "1.0"
paths:
/limited:
get:
operationId: getLimited
responses:
"429":
description: Too many requests
headers:
X-Request-Id:
description: Request correlation identifier
required: true
schema:
type: string
Retry-After:
$ref: "#/components/headers/RetryAfter"
components:
headers:
RetryAfter:
description: Delay before retrying
schema:
type: integer
"##,
);
let headers = &ir.operations[0].responses[0].headers;
assert_eq!(headers.len(), 2);
assert_eq!(
headers["X-Request-Id"].type_expr,
IrTypeExpr::Primitive(IrPrimitive::String)
);
assert!(headers["X-Request-Id"].required);
assert_eq!(
headers["Retry-After"].type_expr,
IrTypeExpr::Primitive(IrPrimitive::Integer)
);
assert!(!headers["Retry-After"].required);
}
#[test]
fn test_cyclic_response_header_reference_returns_error() {
let yaml = r##"
openapi: "3.2.0"
info:
title: Test
version: "1.0"
paths:
/limited:
get:
responses:
"429":
description: Too many requests
headers:
Retry-After:
$ref: "#/components/headers/RetryAfter"
components:
headers:
RetryAfter:
$ref: "#/components/headers/RetryAfter"
"##;
let parsed = crate::parser::parse_content_yaml(yaml).unwrap();
let error = lower_v32(parsed.as_v32().unwrap()).unwrap_err();
assert!(matches!(
error,
LowerError::Other { message }
if message.contains("cyclic response header reference")
));
}
#[test]
fn test_lower_array_alias() {
let ir = lower_yaml(
r#"
openapi: "3.2.0"
info:
title: Test
version: "1.0"
components:
schemas:
Tags:
type: array
items:
type: string
"#,
);
let tags = &ir.schemas["Tags"];
if let IrSchemaKind::Alias(IrTypeExpr::Array(inner)) = &tags.kind {
assert_eq!(**inner, IrTypeExpr::Primitive(IrPrimitive::String));
} else {
panic!("Expected Array alias");
}
}
#[test]
fn test_lower_ref_schema() {
let ir = lower_yaml(
r##"
openapi: "3.2.0"
info:
title: Test
version: "1.0"
components:
schemas:
Pet:
type: object
properties:
name:
type: string
MyPet:
$ref: "#/components/schemas/Pet"
"##,
);
assert_eq!(ir.schemas.len(), 2);
let my_pet = &ir.schemas["MyPet"];
if let IrSchemaKind::Alias(IrTypeExpr::Named(name)) = &my_pet.kind {
assert_eq!(name, "Pet");
} else {
panic!("Expected Named alias");
}
}
#[test]
fn test_lower_query_method() {
let ir = lower_yaml(
r#"
openapi: "3.2.0"
info:
title: Test
version: "1.0"
paths:
/search:
query:
operationId: searchItems
responses:
"200":
description: OK
"#,
);
assert_eq!(ir.operations.len(), 1);
let op = &ir.operations[0];
assert_eq!(op.operation_id, "searchItems");
assert_eq!(op.method, "QUERY");
assert_eq!(op.path, "/search");
}
#[test]
fn test_lower_item_schema_streaming() {
let ir = lower_yaml(
r#"
openapi: "3.2.0"
info:
title: Test
version: "1.0"
paths:
/events:
get:
operationId: streamEvents
responses:
"200":
description: Event stream
content:
text/event-stream:
schema:
type: string
itemSchema:
type: object
properties:
event:
type: string
data:
type: string
"#,
);
assert_eq!(ir.operations.len(), 1);
let resp = &ir.operations[0].responses[0];
assert!(!resp.item_content.is_empty());
assert!(resp.item_content.contains_key("text/event-stream"));
}
#[test]
fn test_lower_tagged_union_with_plain_internal_variants() {
let ir = lower_yaml(
r#"
openapi: "3.2.0"
info:
title: Test
version: "1.0"
paths: {}
components:
schemas:
ScheduleRule:
oneOf:
- type: object
required: [type, at]
properties:
type:
type: string
enum: [once]
at:
type: string
- type: object
required: [type, every_minutes, anchor_at]
properties:
type:
type: string
enum: [interval]
every_minutes:
type: integer
format: int32
anchor_at:
type: string
"#,
);
let schedule_rule = &ir.schemas["ScheduleRule"];
let IrSchemaKind::TaggedUnion(tu) = &schedule_rule.kind else {
panic!("Expected TaggedUnion, got {:?}", schedule_rule.kind);
};
assert!(matches!(tu.tagging, TaggingStyle::Internal));
assert_eq!(tu.discriminator_field, "type");
assert_eq!(tu.variants.len(), 2);
assert_eq!(tu.variants[0].discriminator_value, "once");
assert_eq!(tu.variants[1].discriminator_value, "interval");
}
}