// Copyright 2023 Oxide Computer Company use indexmap::IndexMap; use openapiv3::AnySchema; use schemars::schema::SingleOrVec; use serde_json::Value; pub trait ToSchema { fn to_schema(&self) -> schemars::schema::Schema; } trait Convert { fn convert(&self) -> T; } impl ToSchema for openapiv3::Schema { fn to_schema(&self) -> schemars::schema::Schema { self.convert() } } impl ToSchema for openapiv3::ReferenceOr { fn to_schema(&self) -> schemars::schema::Schema { self.convert() } } impl Convert> for Vec where I: Convert, { fn convert(&self) -> Vec { self.iter().map(Convert::convert).collect() } } impl Convert>> for Vec where I: Convert, { fn convert(&self) -> Option> { if self.is_empty() { None } else { Some(self.iter().map(Convert::convert).collect()) } } } impl Convert for openapiv3::ReferenceOr { fn convert(&self) -> schemars::schema::Schema { match self { openapiv3::ReferenceOr::Reference { reference } => { schemars::schema::SchemaObject::new_ref(reference.clone()) .into() } openapiv3::ReferenceOr::Item(schema) => schema.convert(), } } } impl Convert for openapiv3::ReferenceOr> where openapiv3::ReferenceOr: Convert, T: Clone, { fn convert(&self) -> TT { self.clone().unbox().convert() } } impl Convert for openapiv3::Schema { fn convert(&self) -> schemars::schema::Schema { // TODO the discriminator field is used in a way that seems both // important and unfortunately redundant. It corresponds to the same // regime as internally tagged enums in the serde sense: a field that // the discriminator defines is used to determine which schema is // valid. This can base used in two ways: // 1. It can be used within a struct to identify a particular, required // field. This is typically done on a "base" class in an OOP hierarchy. // Child class structs "extend" that base class by using an allOf // construction where the parent is one of the subschemas. To recognize // this case, we need to check all subschemas in an allOf to see if any // of them have a discriminator. If they do, we can simply construct an // additional structure for the allOf that has a fixed value for that // field. // 2. It can be used within a oneOf or anyOf schema to determine which // subschema is relevant. This is easier to detect because it doesn't // require chasing references. For each subschema we can then make it // an allOf union of the actual subschema along with a fixed-field // structure. let openapiv3::SchemaData { nullable, discriminator: _, // TODO: see above external_docs: _, // TODO: append to description? title, description, default, deprecated, read_only, write_only, example, extensions, } = self.schema_data.clone(); let metadata = schemars::schema::Metadata { id: None, title, description, default, deprecated, read_only, write_only, examples: example.into_iter().collect::>(), }; let metadata = Some(Box::new(metadata)).reduce(); let extensions = extensions.into_iter().collect(); match &self.schema_kind { openapiv3::SchemaKind::Type(openapiv3::Type::String( openapiv3::StringType { format, pattern, enumeration, min_length, max_length, }, )) => schemars::schema::SchemaObject { metadata, instance_type: instance_type( schemars::schema::InstanceType::String, nullable, ), format: format.convert(), enum_values: enumeration.convert(), string: Some(Box::new(schemars::schema::StringValidation { max_length: max_length.convert(), min_length: min_length.convert(), pattern: pattern.clone(), })) .reduce(), extensions, ..Default::default() }, openapiv3::SchemaKind::Type(openapiv3::Type::Number( openapiv3::NumberType { format, multiple_of, exclusive_minimum, exclusive_maximum, minimum, maximum, enumeration, }, )) => { let (maximum, exclusive_maximum) = match (maximum, exclusive_maximum) { (v, true) => (None, *v), (v, false) => (*v, None), }; let (minimum, exclusive_minimum) = match (minimum, exclusive_minimum) { (v, true) => (None, *v), (v, false) => (*v, None), }; schemars::schema::SchemaObject { metadata, instance_type: instance_type( schemars::schema::InstanceType::Number, nullable, ), format: format.convert(), enum_values: enumeration.convert(), number: Some(Box::new( schemars::schema::NumberValidation { multiple_of: *multiple_of, maximum, exclusive_maximum, minimum, exclusive_minimum, }, )) .reduce(), extensions, ..Default::default() } } openapiv3::SchemaKind::Type(openapiv3::Type::Integer( openapiv3::IntegerType { format, multiple_of, exclusive_minimum, exclusive_maximum, minimum, maximum, enumeration, }, )) => { let (maximum, exclusive_maximum) = match (maximum, exclusive_maximum) { (Some(v), true) => (None, Some(*v as f64)), (Some(v), false) => (Some(*v as f64), None), (None, _) => (None, None), }; let (minimum, exclusive_minimum) = match (minimum, exclusive_minimum) { (Some(v), true) => (None, Some(*v as f64)), (Some(v), false) => (Some(*v as f64), None), (None, _) => (None, None), }; schemars::schema::SchemaObject { metadata, instance_type: instance_type( schemars::schema::InstanceType::Integer, nullable, ), format: format.convert(), enum_values: enumeration.convert(), number: Some(Box::new( schemars::schema::NumberValidation { multiple_of: multiple_of .map(|v| v as f64) .convert(), maximum, exclusive_maximum, minimum, exclusive_minimum, }, )) .reduce(), extensions, ..Default::default() } } openapiv3::SchemaKind::Type(openapiv3::Type::Object( openapiv3::ObjectType { properties, required, additional_properties, min_properties, max_properties, }, )) => schemars::schema::SchemaObject { metadata, instance_type: instance_type( schemars::schema::InstanceType::Object, nullable, ), object: Some(Box::new(schemars::schema::ObjectValidation { max_properties: max_properties.convert(), min_properties: min_properties.convert(), required: required.convert(), properties: properties.convert(), pattern_properties: schemars::Map::default(), additional_properties: additional_properties.convert(), property_names: None, })) .reduce(), extensions, ..Default::default() }, openapiv3::SchemaKind::Type(openapiv3::Type::Array( openapiv3::ArrayType { items, min_items, max_items, unique_items, }, )) => schemars::schema::SchemaObject { metadata, instance_type: instance_type( schemars::schema::InstanceType::Array, nullable, ), array: Some(Box::new(schemars::schema::ArrayValidation { items: items.as_ref().map(|items| { schemars::schema::SingleOrVec::Single(Box::new( items.convert(), )) }), additional_items: None, max_items: max_items.convert(), min_items: min_items.convert(), unique_items: if *unique_items { Some(true) } else { None }, contains: None, })) .reduce(), extensions, ..Default::default() }, openapiv3::SchemaKind::Type(openapiv3::Type::Boolean( openapiv3::BooleanType { enumeration }, )) => schemars::schema::SchemaObject { metadata, instance_type: instance_type( schemars::schema::InstanceType::Boolean, nullable, ), enum_values: enumeration.convert(), extensions, ..Default::default() }, openapiv3::SchemaKind::OneOf { one_of } => { schemars::schema::SchemaObject { metadata, subschemas: Some(Box::new( schemars::schema::SubschemaValidation { one_of: Some(one_of.convert()), ..Default::default() }, )), extensions, ..Default::default() } } openapiv3::SchemaKind::AllOf { all_of } => { schemars::schema::SchemaObject { metadata, subschemas: Some(Box::new( schemars::schema::SubschemaValidation { all_of: Some(all_of.convert()), ..Default::default() }, )), extensions, ..Default::default() } } openapiv3::SchemaKind::AnyOf { any_of } => { schemars::schema::SchemaObject { metadata, subschemas: Some(Box::new( schemars::schema::SubschemaValidation { any_of: Some(any_of.convert()), ..Default::default() }, )), extensions, ..Default::default() } } openapiv3::SchemaKind::Not { not } => { schemars::schema::SchemaObject { metadata, subschemas: Some(Box::new( schemars::schema::SubschemaValidation { not: Some(Box::new(not.convert())), ..Default::default() }, )), extensions, ..Default::default() } } // This is the permissive schema that allows anything to match. openapiv3::SchemaKind::Any(AnySchema { typ: None, pattern: None, multiple_of: None, exclusive_minimum: None, exclusive_maximum: None, minimum: None, maximum: None, properties, required, additional_properties: None, min_properties: None, max_properties: None, items: None, min_items: None, max_items: None, unique_items: None, format: None, enumeration, min_length: None, max_length: None, one_of, all_of, any_of, not: None, }) if properties.is_empty() && required.is_empty() && enumeration.is_empty() && one_of.is_empty() && all_of.is_empty() && any_of.is_empty() => { schemars::schema::SchemaObject { metadata, extensions, ..Default::default() } } // A simple null value. openapiv3::SchemaKind::Any(AnySchema { typ: None, pattern: None, multiple_of: None, exclusive_minimum: None, exclusive_maximum: None, minimum: None, maximum: None, properties, required, additional_properties: None, min_properties: None, max_properties: None, items: None, min_items: None, max_items: None, unique_items: None, format: None, enumeration, min_length: None, max_length: None, one_of, all_of, any_of, not: None, }) if properties.is_empty() && required.is_empty() && enumeration.len() == 1 && enumeration[0] == serde_json::Value::Null && one_of.is_empty() && all_of.is_empty() && any_of.is_empty() => { schemars::schema::SchemaObject { metadata, instance_type: Some( schemars::schema::InstanceType::Null.into(), ), extensions, ..Default::default() } } openapiv3::SchemaKind::Any(AnySchema { typ, pattern, multiple_of, exclusive_minimum, exclusive_maximum, minimum, maximum, properties, required, additional_properties, min_properties, max_properties, items, min_items, max_items, unique_items, enumeration, format, min_length, max_length, one_of, all_of, any_of, not, }) => { let mut so = schemars::schema::SchemaObject { metadata, extensions, ..Default::default() }; // General if let Some(format) = format { so.format = Some(format.clone()); } if !enumeration.is_empty() { so.enum_values = Some(enumeration.clone()); } // String if let Some(pattern) = pattern { so.string().pattern = Some(pattern.clone()); } if let Some(min_length) = min_length { so.string().min_length = Some(*min_length as u32); } if let Some(max_length) = max_length { so.string().max_length = Some(*max_length as u32); } // Number if let Some(multiple_of) = multiple_of { so.number().multiple_of = Some(*multiple_of); } match (minimum, exclusive_minimum) { (None, Some(true)) => { todo!("exclusive_minimum set without minimum"); } (None, _) => (), (Some(minimum), Some(true)) => { so.number().exclusive_minimum = Some(*minimum); } (Some(minimum), _) => { so.number().minimum = Some(*minimum); } } match (maximum, exclusive_maximum) { (None, Some(true)) => { todo!("exclusive_maximum set without maximum"); } (None, _) => (), (Some(maximum), Some(true)) => { so.number().exclusive_maximum = Some(*maximum); } (Some(maximum), _) => { so.number().maximum = Some(*maximum); } } // Object if !properties.is_empty() { so.object().properties = properties.convert(); } if !required.is_empty() { so.object().required = required.convert(); } if additional_properties.is_some() { so.object().additional_properties = additional_properties.convert(); } if let Some(min_properties) = min_properties { so.object().min_properties = Some(*min_properties as u32); } if let Some(max_properties) = max_properties { so.object().max_properties = Some(*max_properties as u32); } // Array if items.is_some() { so.array().items = items.convert().clone().map(SingleOrVec::Single); } if let Some(min_items) = min_items { so.array().min_items = Some(*min_items as u32); } if let Some(max_items) = max_items { so.array().max_items = Some(*max_items as u32); } if let Some(unique_items) = unique_items { so.array().unique_items = Some(*unique_items); } // Subschemas if !one_of.is_empty() { so.subschemas().one_of = one_of.convert(); } if !all_of.is_empty() { so.subschemas().all_of = all_of.convert(); } if !any_of.is_empty() { so.subschemas().any_of = any_of.convert(); } if not.is_some() { so.subschemas().not = not.convert(); } // We do this last so we can infer types if none are specified. match (typ.as_deref(), nullable) { (Some("boolean"), _) => { so.instance_type = instance_type( schemars::schema::InstanceType::Boolean, nullable, ); } (Some("object"), _) => { so.instance_type = instance_type( schemars::schema::InstanceType::Object, nullable, ); } (Some("array"), _) => { so.instance_type = instance_type( schemars::schema::InstanceType::Array, nullable, ); } (Some("number"), _) => { so.instance_type = instance_type( schemars::schema::InstanceType::Number, nullable, ); } (Some("string"), _) => { so.instance_type = instance_type( schemars::schema::InstanceType::String, nullable, ); } (Some("integer"), _) => { so.instance_type = instance_type( schemars::schema::InstanceType::Integer, nullable, ); } (Some(typ), _) => todo!("invalid type: {}", typ), // No types (None, false) => (), // We only try to infer types if we need to add in an // additional null type; otherwise we can leave the type // implied. (None, true) => { let instance_types = [ so.object.is_some().then_some( schemars::schema::InstanceType::Object, ), so.array.is_some().then_some( schemars::schema::InstanceType::Array, ), so.number.is_some().then_some( schemars::schema::InstanceType::Array, ), so.string.is_some().then_some( schemars::schema::InstanceType::Array, ), nullable.then_some( schemars::schema::InstanceType::Null, ), ] .into_iter() .flatten() .collect::>(); // TODO we could also look at enumerated values. so.instance_type = match ( instance_types.first(), instance_types.len(), ) { (Some(typ), 1) => { Some(SingleOrVec::Single(Box::new(*typ))) } (Some(_), _) => { Some(SingleOrVec::Vec(instance_types)) } (None, _) => None, }; } }; so } } .into() } } impl Convert> for openapiv3::VariantOrUnknownOrEmpty where T: Convert, { fn convert(&self) -> Option { match self { openapiv3::VariantOrUnknownOrEmpty::Item(i) => Some(i.convert()), openapiv3::VariantOrUnknownOrEmpty::Unknown(s) => Some(s.clone()), openapiv3::VariantOrUnknownOrEmpty::Empty => None, } } } impl Convert for openapiv3::StringFormat { fn convert(&self) -> String { match self { openapiv3::StringFormat::Date => "date", openapiv3::StringFormat::DateTime => "date-time", openapiv3::StringFormat::Password => "password", openapiv3::StringFormat::Byte => "byte", openapiv3::StringFormat::Binary => "binary", } .to_string() } } impl Convert for openapiv3::NumberFormat { fn convert(&self) -> String { match self { openapiv3::NumberFormat::Float => "float", openapiv3::NumberFormat::Double => "double", } .to_string() } } impl Convert for openapiv3::IntegerFormat { fn convert(&self) -> String { match self { openapiv3::IntegerFormat::Int32 => "int32", openapiv3::IntegerFormat::Int64 => "int64", } .to_string() } } impl Convert for Option { fn convert(&self) -> Value { match self { Some(value) => Value::String(value.clone()), None => Value::Null, } } } impl Convert for Option { fn convert(&self) -> Value { match self { Some(value) => { Value::Number(serde_json::Number::from_f64(*value).unwrap()) } None => Value::Null, } } } impl Convert for Option { fn convert(&self) -> Value { match self { Some(value) => Value::Number(serde_json::Number::from(*value)), None => Value::Null, } } } impl Convert for Option { fn convert(&self) -> Value { match self { Some(value) => Value::Bool(*value), None => Value::Null, } } } fn instance_type( instance_type: schemars::schema::InstanceType, nullable: bool, ) -> Option> { if nullable { Some(vec![instance_type, schemars::schema::InstanceType::Null].into()) } else { Some(instance_type.into()) } } impl Convert> for Option { fn convert(&self) -> Option { (*self).map(|m| m as u32) } } impl Convert> for Option { fn convert(&self) -> Option { *self } } impl Convert> for Vec { fn convert(&self) -> schemars::Set { self.iter().cloned().collect() } } impl Convert> for IndexMap>> { fn convert(&self) -> schemars::Map { self.iter().map(|(k, v)| (k.clone(), v.convert())).collect() } } impl Convert for Box where T: Convert, { fn convert(&self) -> TT { self.as_ref().convert() } } impl Convert>> for Option where T: Convert, { fn convert(&self) -> Option> { self.as_ref().map(|t| Box::new(t.convert())) } } impl Convert for openapiv3::AdditionalProperties { fn convert(&self) -> schemars::schema::Schema { match self { openapiv3::AdditionalProperties::Any(b) => { schemars::schema::Schema::Bool(*b) } openapiv3::AdditionalProperties::Schema(schema) => schema.convert(), } } } trait OptionReduce { fn reduce(self) -> Self; } // If an Option is `Some` of it's default value, we can simplify that to `None` impl OptionReduce for Option where T: Default + PartialEq + std::fmt::Debug, { fn reduce(self) -> Self { match &self { Some(s) if s != &T::default() => self, _ => None, } } } #[cfg(test)] mod tests { use serde_json::json; use crate::to_schema::Convert; #[test] fn test_null() { let schema_value = json!({ "enum": [null] }); let oa_schema = serde_json::from_value::(schema_value).unwrap(); let schema = oa_schema.convert(); assert_eq!( schema.into_object().instance_type, Some(schemars::schema::SingleOrVec::Single(Box::new( schemars::schema::InstanceType::Null ))) ); } #[test] fn test_weird_integer() { let schema_value = json!({ "type": "integer", "minimum": 0.0, }); let oa_schema = serde_json::from_value::(schema_value.clone()) .unwrap(); let js_schema = serde_json::from_value::(schema_value) .unwrap(); let conv_schema = oa_schema.convert(); assert_eq!(conv_schema, js_schema); } #[test] fn test_object_no_type() { let schema_value = json!({ "properties": { "foo": {} } }); let oa_schema = serde_json::from_value::(schema_value.clone()) .unwrap(); let js_schema = serde_json::from_value::(schema_value) .unwrap(); let conv_schema = oa_schema.convert(); assert_eq!(conv_schema, js_schema); } #[test] fn test_array_no_type() { let schema_value = json!({ "items": {} }); let oa_schema = serde_json::from_value::(schema_value.clone()) .unwrap(); let js_schema = serde_json::from_value::(schema_value) .unwrap(); let conv_schema = oa_schema.convert(); assert_eq!(conv_schema, js_schema); } #[test] fn test_number_no_type() { let schema_value = json!({ "minimum": 100.0 }); let oa_schema = serde_json::from_value::(schema_value.clone()) .unwrap(); let js_schema = serde_json::from_value::(schema_value) .unwrap(); let conv_schema = oa_schema.convert(); assert_eq!(conv_schema, js_schema); } #[test] fn test_solo_enum() { let schema_value = json!({ "enum": ["one"] }); let oa_schema = serde_json::from_value::(schema_value.clone()) .unwrap(); let js_schema = serde_json::from_value::(schema_value) .unwrap(); let conv_schema = oa_schema.convert(); assert_eq!(conv_schema, js_schema); } }