use crate::model::{
ElementId, GlobalType, GlobalTypeProperty, GlobalTypeValue, InputProperty, IntegerEnumElement,
NumberEnumElement, OutputProperty, Provider, Ref, StringEnumElement,
};
use crate::utils::fix_description;
use anyhow::{Context, Result, anyhow};
use convert_case::{Case, Casing};
use pulumi_gestalt_serde_constant_string::generate_string_const;
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet, HashSet};
type PulumiMap<T> = BTreeMap<String, T>;
#[derive(Deserialize, Debug)]
pub(crate) enum TypeEnum {
#[serde(alias = "boolean")]
Boolean,
#[serde(alias = "integer")]
Integer,
#[serde(alias = "number")]
Number,
#[serde(alias = "string")]
String,
#[serde(alias = "array")]
Array,
#[serde(alias = "object")]
Object,
#[serde(untagged)]
Other(String),
}
#[derive(Deserialize, Debug)]
struct Type {
#[serde(rename = "type")]
type_: Option<TypeEnum>,
description: Option<String>,
#[serde(rename = "$ref")]
ref_: Option<String>,
items: Option<Box<Type>>,
#[serde(rename = "additionalProperties")]
additional_properties: Option<Box<Type>>,
#[serde(rename = "oneOf")]
one_of: Option<Vec<OneOfType>>,
#[serde(rename = "const")]
const_: Option<String>,
}
#[derive(Deserialize, Debug)]
#[serde(untagged)]
enum OneOfType {
Ref(OneOfTypeRef),
Primitive(OneOfTypePrimitive),
Array(OneOfTypeArray),
Object(OneOfTypeObject),
}
#[derive(Deserialize, Debug)]
struct OneOfTypeRef {
#[serde(rename = "$ref")]
ref_: String,
}
#[derive(Deserialize, Debug)]
struct OneOfTypePrimitive {
#[serde(rename = "type")]
type_: OneOfTypePrimitiveType,
}
#[derive(Deserialize, Debug)]
struct OneOfTypeArray {
#[serde(rename = "type")]
#[allow(dead_code)]
type_: ArrayConstant,
items: OneOfTypePrimitive,
}
#[derive(Deserialize, Debug)]
struct OneOfTypeObject {
#[serde(rename = "type")]
#[allow(dead_code)]
type_: ObjectConstant,
#[serde(rename = "additionalProperties")]
additional_properties: OneOfTypePrimitive,
}
generate_string_const!(ObjectConstant, "object");
generate_string_const!(ArrayConstant, "array");
#[derive(Deserialize, Debug)]
enum OneOfTypePrimitiveType {
#[serde(rename = "string")]
String,
}
#[derive(Deserialize, Debug)]
struct Property {
#[serde(flatten)]
r#type: Type,
}
#[derive(Deserialize, Debug)]
struct ObjectType {
description: Option<String>,
r#type: Option<TypeEnum>,
#[serde(default)]
properties: PulumiMap<Property>,
#[serde(default)]
required: BTreeSet<String>,
#[serde(rename = "enum")]
enum_: Option<ObjectTypeEnum>,
}
#[derive(Deserialize, Debug)]
struct Resource {
#[serde(flatten)]
object_type: ObjectType,
#[serde(default, rename = "inputProperties")]
input_properties: PulumiMap<Property>,
#[serde(default, rename = "requiredInputs")]
required_inputs: BTreeSet<String>,
}
#[derive(Deserialize, Debug)]
#[serde(untagged)]
enum ObjectTypeEnum {
String(Vec<StringEnumValue>),
Integer(Vec<IntegerEnumValue>),
Number(Vec<NumberEnumValue>),
}
#[derive(Deserialize, Debug)]
struct IntegerEnumValue {
name: String,
description: Option<String>,
value: i64,
}
#[derive(Deserialize, Debug)]
struct NumberEnumValue {
name: String,
description: Option<String>,
value: f64,
}
#[derive(Deserialize, Debug)]
struct StringEnumValue {
name: Option<String>,
description: Option<String>,
value: Option<String>,
}
#[derive(Deserialize, Debug)]
struct ComplexType {
#[serde(flatten)]
object_type: ObjectType,
}
#[derive(Deserialize, Debug)]
struct Function {
description: Option<String>,
inputs: Option<ObjectType>,
outputs: Option<ObjectType>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Package {
name: String,
#[serde(rename = "displayName")]
display_name: Option<String>,
#[serde(rename = "pluginDownloadURL")]
plugin_download_url: Option<String>,
#[serde(default)]
resources: PulumiMap<Resource>,
#[serde(default)]
provider: Option<Resource>,
version: Option<String>,
#[serde(default)]
types: PulumiMap<ComplexType>,
#[serde(default)]
functions: PulumiMap<Function>,
}
fn new_type_mapper(type_: &Type) -> Result<crate::model::Type> {
(match type_ {
Type { ref_: Some(r), .. } => Ok(crate::model::Type::Ref(
Ref::new(r).context(format!("Cannot convert ref fo type {type_:?}"))?,
)),
Type {
type_: Some(TypeEnum::Boolean),
..
} => Ok(crate::model::Type::Boolean),
Type {
type_: Some(TypeEnum::Integer),
..
} => Ok(crate::model::Type::Integer),
Type {
type_: Some(TypeEnum::Number),
..
} => Ok(crate::model::Type::Number),
Type {
type_: Some(TypeEnum::String),
const_: Some(const_),
..
} => Ok(crate::model::Type::ConstString(const_.clone())),
Type {
type_: Some(TypeEnum::String),
..
} => Ok(crate::model::Type::String),
Type {
type_: Some(TypeEnum::Array),
items: Some(items),
..
} => Ok(crate::model::Type::Array(Box::new(new_type_mapper(items)?))),
Type {
type_: Some(TypeEnum::Array),
..
} => Err(anyhow!("Array does not have 'items' field")),
Type {
type_: Some(TypeEnum::Object),
additional_properties: Some(property),
..
} => Ok(crate::model::Type::Object(Box::new(new_type_mapper(
property,
)?))),
Type {
type_: Some(TypeEnum::Object),
..
} => Err(anyhow!("Object does not have 'additionalProperties' field")),
Type {
one_of: Some(one_of),
..
} => create_discriminated_union(one_of),
Type {
type_: None,
ref_: None,
..
} => Err(anyhow!("'type' and 'ref' fields cannot be empty")),
Type {
type_: Some(TypeEnum::Other(other)),
ref_: None,
one_of: None,
..
} => Err(anyhow!("Unknown type: [{}]", other)),
})
.context(format!("Cannot handle type: [{type_:?}]"))
}
fn create_discriminated_union(one_of: &[OneOfType]) -> Result<crate::model::Type> {
Ok(crate::model::Type::DiscriminatedUnion(
one_of
.iter()
.map(|r| match r {
OneOfType::Ref(r) => crate::model::Type::Ref(
Ref::new(&r.ref_)
.context(format!("Cannot convert ref fo type {r:?}"))
.unwrap(),
),
OneOfType::Primitive(primitive) => match primitive.type_ {
OneOfTypePrimitiveType::String => crate::model::Type::String,
},
OneOfType::Array(arr) => match arr.items.type_ {
OneOfTypePrimitiveType::String => {
crate::model::Type::Array(Box::new(crate::model::Type::String))
}
},
OneOfType::Object(obj) => match obj.additional_properties.type_ {
OneOfTypePrimitiveType::String => {
crate::model::Type::Object(Box::new(crate::model::Type::String))
}
},
})
.collect(),
))
}
fn resource_to_model(
resource_name: &str,
resource: &Resource,
) -> Result<(ElementId, crate::model::Resource)> {
let element_id = ElementId::new(resource_name)?;
Ok((
element_id.clone(),
crate::model::Resource {
element_id: element_id.clone(),
description: fix_description(resource.object_type.description.clone(), &element_id),
input_properties: convert_input_properties(
&resource.input_properties,
&resource.required_inputs,
)?,
output_properties: convert_output_property_object_type(
&element_id,
&resource.object_type,
)?,
},
))
}
fn function_to_model(
function_name: &str,
function: &Function,
) -> Result<(ElementId, crate::model::Function)> {
let element_id = ElementId::new(function_name)?;
Ok((
element_id.clone(),
crate::model::Function {
element_id: element_id.clone(),
description: fix_description(function.description.clone(), &element_id),
input_properties: match &function.inputs {
None => vec![],
Some(input) => convert_input_property_object_type(input)?,
},
output_properties: match &function.outputs {
None => vec![],
Some(output) => convert_output_property_object_type(&element_id, output)?,
},
},
))
}
fn provider_to_model(provider: Option<&Resource>) -> Result<Provider> {
let Some(provider) = provider else {
return Ok(Provider::default());
};
Ok(Provider {
description: provider.object_type.description.clone(),
input_properties: convert_input_properties(
&provider.input_properties,
&provider.required_inputs,
)?,
output_properties: convert_output_property_object_type_without_forced(
&provider.object_type,
)?,
})
}
fn convert_input_properties(
properties: &PulumiMap<Property>,
required: &BTreeSet<String>,
) -> Result<Vec<InputProperty>> {
properties
.iter()
.map(|(input_name, input_property)| {
let mut type_ = new_type_mapper(&input_property.r#type)
.context(format!("Cannot handle [{input_name}] type"))?;
if !required.contains(input_name) {
type_ = crate::model::Type::Option(Box::new(type_));
}
Ok(InputProperty {
name: input_name.clone(),
r#type: type_,
description: input_property.r#type.description.clone(),
})
})
.collect::<Result<Vec<_>>>()
}
fn convert_input_property_object_type(object_type: &ObjectType) -> Result<Vec<InputProperty>> {
convert_input_properties(&object_type.properties, &object_type.required)
}
fn convert_output_property_object_type(
element_id: &ElementId,
object_type: &ObjectType,
) -> Result<Vec<OutputProperty>> {
let forced_options = invalid_required_complextype_required_fields();
object_type
.properties
.iter()
.map(|(output_name, output_property)| {
let mut type_ = new_type_mapper(&output_property.r#type)
.context(format!("Cannot handle [{output_name}] type"))?;
if !object_type.required.contains(output_name)
|| forced_options.contains(&(element_id.clone(), output_name.clone()))
{
type_ = crate::model::Type::Option(Box::new(type_));
}
Ok(OutputProperty {
name: output_name.clone(),
r#type: type_,
description: output_property.r#type.description.clone(),
})
})
.collect::<Result<Vec<_>>>()
}
fn convert_output_property_object_type_without_forced(
object_type: &ObjectType,
) -> Result<Vec<OutputProperty>> {
object_type
.properties
.iter()
.map(|(output_name, output_property)| {
let mut type_ = new_type_mapper(&output_property.r#type)
.context(format!("Cannot handle [{output_name}] type"))?;
if !object_type.required.contains(output_name) {
type_ = crate::model::Type::Option(Box::new(type_));
}
Ok(OutputProperty {
name: output_name.clone(),
r#type: type_,
description: output_property.r#type.description.clone(),
})
})
.collect::<Result<Vec<_>>>()
}
pub(crate) fn to_model(package: &Package) -> Result<crate::model::Package> {
let resources = package
.resources
.iter()
.map(|(resource_name, resource)| resource_to_model(resource_name, resource))
.collect::<Result<BTreeMap<_, _>>>()
.context("Cannot handle resources")?;
let provider =
provider_to_model(package.provider.as_ref()).context("Cannot handle provider")?;
let functions = package
.functions
.iter()
.map(|(function_name, function)| function_to_model(function_name, function))
.collect::<Result<BTreeMap<_, _>>>()
.context("Cannot handle functions")?;
let types = package
.types
.iter()
.map(|(type_name, type_)| {
let r = convert_to_global_type(type_name, &type_)?;
Ok((r.element_id.clone(), r))
})
.collect::<Result<BTreeMap<_, _>>>()
.context("Cannot handle types")?;
Ok(crate::model::Package::new(
package.name.clone(),
package.display_name.clone(),
package.plugin_download_url.clone(),
package.version.clone().unwrap_or("0.0.1".to_string()),
provider,
resources,
functions,
types,
))
}
fn convert_to_global_type(type_name: &String, type_: &&ComplexType) -> Result<GlobalType> {
let element_id = ElementId::new(type_name)?;
let tpe = match &type_.object_type {
ObjectType { r#type: None, .. } => Err(anyhow!("Unknown complex type")),
ObjectType {
r#type: Some(TypeEnum::Object),
..
} => Ok(GlobalTypeValue::Object(
type_.object_type.description.clone(),
convert_output_property_object_type(&element_id, &type_.object_type)?
.iter()
.map(|p| GlobalTypeProperty {
name: p.name.clone(),
r#type: p.r#type.clone(),
description: p.description.clone(),
})
.collect(),
)),
ObjectType {
r#type: Some(TypeEnum::Array),
..
} => Err(anyhow!("Array not supported")),
ObjectType {
r#type: Some(TypeEnum::Boolean),
..
} => Err(anyhow!("Boolean not supported")),
ObjectType {
r#type: Some(TypeEnum::Integer),
enum_: Some(ObjectTypeEnum::Integer(enum_cases)),
description,
..
} => Ok(create_integer_enum(description, enum_cases)),
ObjectType {
r#type: Some(TypeEnum::Integer),
enum_: Some(e),
..
} => Err(anyhow!("Invalid integer enum combination {:?}", e)),
ObjectType {
r#type: Some(TypeEnum::Integer),
..
} => Err(anyhow!("Invalid integer without enum")),
ObjectType {
r#type: Some(TypeEnum::Number),
enum_: Some(ObjectTypeEnum::Number(enum_cases)),
description,
..
} => Ok(create_number_enum(description, enum_cases)),
ObjectType {
r#type: Some(TypeEnum::Number),
enum_: Some(ObjectTypeEnum::Integer(enum_cases)),
description,
..
} => Ok(create_number_integer_enum(description, enum_cases)),
ObjectType {
r#type: Some(TypeEnum::Number),
enum_: Some(e),
..
} => Err(anyhow!("Invalid number enum combination {:?}", e)),
ObjectType {
r#type: Some(TypeEnum::Number),
..
} => Err(anyhow!("Invalid number without enum")),
ObjectType {
r#type: Some(TypeEnum::String),
enum_: Some(ObjectTypeEnum::String(enum_cases)),
description,
..
} => Ok(create_string_enum(description, enum_cases)),
ObjectType {
r#type: Some(TypeEnum::String),
enum_: Some(e),
..
} => Err(anyhow!("Invalid string enum combination {:?}", e)),
ObjectType {
r#type: Some(TypeEnum::String),
..
} => Err(anyhow!("Invalid string without enum")),
ObjectType {
r#type: Some(TypeEnum::Other(other)),
..
} => Err(anyhow!("Unknown type: [{}]", other)),
}
.context(format!("Cannot convert type [{type_name}]"))?;
Ok(GlobalType {
element_id: element_id.clone(),
value: tpe,
})
}
fn create_number_integer_enum(
description: &Option<String>,
enum_values: &[IntegerEnumValue],
) -> GlobalTypeValue {
GlobalTypeValue::NumberEnum(
description.clone(),
enum_values
.iter()
.map(|enum_value| NumberEnumElement {
name: enum_value.name.clone(),
value: enum_value.value as f64,
description: enum_value.description.clone(),
})
.collect(),
)
}
fn create_string_enum(
description: &Option<String>,
enum_values: &[StringEnumValue],
) -> GlobalTypeValue {
GlobalTypeValue::StringEnum(
description.clone(),
enum_values
.iter()
.map(|enum_value| {
let (name, value) = match (&enum_value.name, &enum_value.value) {
(Some(name), Some(value)) => (name, value),
(Some(name), None) => (name, name),
(None, Some(value)) => (value, value),
(None, None) => {
panic!("Invalid enum value: {enum_value:?}")
}
};
StringEnumElement {
name: name.clone(),
value: value.clone(),
description: enum_value.description.clone(),
}
})
.collect(),
)
}
fn create_integer_enum(
description: &Option<String>,
enum_values: &[IntegerEnumValue],
) -> GlobalTypeValue {
GlobalTypeValue::IntegerEnum(
description.clone(),
enum_values
.iter()
.map(|enum_value| IntegerEnumElement {
name: enum_value.name.to_case(Case::UpperCamel),
value: enum_value.value,
description: enum_value.description.clone(),
})
.collect(),
)
}
fn create_number_enum(
description: &Option<String>,
enum_values: &[NumberEnumValue],
) -> GlobalTypeValue {
GlobalTypeValue::NumberEnum(
description.clone(),
enum_values
.iter()
.map(|enum_value| NumberEnumElement {
name: enum_value.name.to_case(Case::UpperCamel),
value: enum_value.value,
description: enum_value.description.clone(),
})
.collect(),
)
}
fn invalid_required_complextype_required_fields() -> HashSet<(ElementId, String)> {
HashSet::from([
(
ElementId::new("docker:index/container:Container").unwrap(),
"containerLogs".to_string(),
),
(
ElementId::new("docker:index/container:Container").unwrap(),
"healthcheck".to_string(),
),
])
}
#[cfg(test)]
mod test {
use crate::filter::filter_package;
use crate::model::{ElementId, Provider};
use crate::schema::to_model;
use anyhow::Result;
use serde_json::json;
#[test]
fn resource_with_invalid_id_fails() -> Result<()> {
let json = json!({
"name": "test",
"version": "0.0.0",
"resources": {
"invalid": {
"description": "test resource",
}
}
});
let err = to_model(&serde_json::from_value(json)?).unwrap_err();
let chain: Vec<_> = anyhow::Chain::new(err.as_ref())
.map(|e| e.to_string())
.collect();
assert_eq!(
vec![
"Cannot handle resources",
"Cannot generate element id from [invalid]",
],
chain
);
Ok(())
}
#[test]
fn object_without_additionalproperties_fails() -> Result<()> {
let json = json!({
"name": "test",
"version": "0.0.0-DEV",
"resources": {
"test:index:test_resource": {
"description": "test resource",
"inputProperties": {
"test_input": {
"type": "object"
}
},
}
}
});
let err = to_model(&serde_json::from_value(json)?).unwrap_err();
let chain: Vec<_> = anyhow::Chain::new(err.as_ref())
.map(|e| e.to_string())
.collect();
assert_eq!(
vec![
"Cannot handle resources",
"Cannot handle [test_input] type",
"Cannot handle type: [Type { type_: Some(Object), description: None, ref_: None, items: None, additional_properties: None, one_of: None, const_: None }]",
"Object does not have 'additionalProperties' field",
],
chain
);
Ok(())
}
#[test]
fn array_without_items_fails() -> Result<()> {
let json = json!({
"name": "test",
"version": "0.0.0-DEV",
"resources": {
"test:index:test_resource": {
"description": "test resource",
"inputProperties": {
"test_input": {
"type": "array"
}
},
}
}
});
let err = to_model(&serde_json::from_value(json)?).unwrap_err();
let chain: Vec<_> = anyhow::Chain::new(err.as_ref())
.map(|e| e.to_string())
.collect();
assert_eq!(
vec![
"Cannot handle resources",
"Cannot handle [test_input] type",
"Cannot handle type: [Type { type_: Some(Array), description: None, ref_: None, items: None, additional_properties: None, one_of: None, const_: None }]",
"Array does not have 'items' field",
],
chain
);
Ok(())
}
#[test]
fn provider_is_mapped_to_model() -> Result<()> {
let json = json!({
"name": "aws",
"version": "0.0.0-DEV",
"provider": {
"description": "provider",
"inputProperties": {
"region": {
"type": "string"
}
},
"requiredInputs": ["region"],
"properties": {
"region": {
"type": "string"
}
},
"required": ["region"]
}
});
let package = to_model(&serde_json::from_value(json)?)?;
let provider = package.provider;
assert_eq!(provider.description, Some("provider".to_string()));
assert_eq!(provider.input_properties.len(), 1);
assert_eq!(provider.output_properties.len(), 1);
assert!(!package.resource_name_map.contains_key("aws:providers:aws"));
Ok(())
}
#[test]
fn provider_is_created_even_if_not_in_schema() -> Result<()> {
let json = json!({
"name": "aws",
"version": "0.0.0-DEV",
});
let package = to_model(&serde_json::from_value(json)?)?;
assert_eq!(
package.provider,
Provider {
description: None,
input_properties: vec![],
output_properties: vec![],
}
);
Ok(())
}
#[test]
fn provider_is_not_filtered_out() -> Result<()> {
let json = json!({
"name": "aws",
"version": "0.0.0-DEV",
"resources": {
"aws:s3/bucket:Bucket": {
"properties": {},
"inputProperties": {}
}
},
"provider": {
"properties": {},
"inputProperties": {}
}
});
let mut package = to_model(&serde_json::from_value(json)?)?;
filter_package(&mut package, &["ec2"]);
assert!(
!package
.resources
.contains_key(&ElementId::new("aws:s3/bucket:Bucket")?)
);
assert!(package.provider.input_properties.is_empty());
assert!(package.provider.output_properties.is_empty());
Ok(())
}
#[test]
fn provider_types_are_kept() -> Result<()> {
let json = json!({
"name": "aws",
"version": "0.0.0-DEV",
"provider": {
"inputProperties": {
"some_type": {
"$ref": "#/types/aws:index:SomeType"
}
},
"properties": {},
},
"types": {
"aws:index:SomeType": {
"type": "object",
"properties": {
"foo": { "type": "string" }
}
}
}
});
let mut package = to_model(&serde_json::from_value(json)?)?;
filter_package(&mut package, &["ec2"]);
assert!(
package
.types
.contains_key(&ElementId::new("aws:index:SomeType")?)
);
Ok(())
}
}