Skip to main content

magma_plugin/
schema.rs

1//! Provider schema → cty implied type (terraform's `Block.ImpliedType`).
2//!
3//! A tfplugin6 resource `Schema` describes a resource as a `Block` of
4//! typed attributes + nested blocks. To marshal a resource's attributes
5//! onto the wire as a [`DynamicValue`](magma_cty::DynamicValue), magma
6//! needs the resource's **implied cty type** — the object type the
7//! `Block` describes. This module derives it, composing
8//! [`magma_cty::CtyType`] with the attributes' go-cty JSON type
9//! encodings (which [`CtyType::from_json`] already parses).
10//!
11//! This is the bridge between `GetProviderSchema` and the apply codec:
12//! `schema → CtyType`, then `magma_cty` encodes the rendered attributes
13//! against that type for `ApplyResourceChange`.
14
15use std::collections::BTreeMap;
16
17use magma_cty::CtyType;
18use magma_protocol::tfplugin6::schema::{self, Attribute, Block, NestedBlock, Object};
19
20/// Moved to `magma-provider-api` — see that crate's header. Re-exported
21/// so `magma_plugin::schema::SchemaError` still resolves.
22pub use magma_provider_api::SchemaError;
23
24/// The implied cty object type of a resource / provider / data-source
25/// `Block` — attributes plus nested blocks, in a single object type.
26pub fn block_implied_type(block: &Block) -> Result<CtyType, SchemaError> {
27    let mut attrs: BTreeMap<String, CtyType> = BTreeMap::new();
28    for attr in &block.attributes {
29        attrs.insert(attr.name.clone(), attribute_type(attr)?);
30    }
31    for nb in &block.block_types {
32        attrs.insert(nb.type_name.clone(), nested_block_type(nb)?);
33    }
34    Ok(CtyType::Object(attrs))
35}
36
37/// An attribute's cty type: a `nested_type` object (wrapped by its
38/// nesting) takes precedence over the scalar go-cty-JSON `type` bytes.
39fn attribute_type(attr: &Attribute) -> Result<CtyType, SchemaError> {
40    if let Some(obj) = &attr.nested_type {
41        return object_implied_type(obj, &attr.name);
42    }
43    if attr.r#type.is_empty() {
44        return Err(SchemaError::AttributeNoType(attr.name.clone()));
45    }
46    let json: serde_json::Value = serde_json::from_slice(&attr.r#type).map_err(|e| {
47        SchemaError::Cty(attr.name.clone(), magma_cty::CtyError::Type(e.to_string()))
48    })?;
49    CtyType::from_json(&json).map_err(|e| SchemaError::Cty(attr.name.clone(), e))
50}
51
52/// A `nested_type` `Object` → cty type, wrapped per its nesting mode.
53fn object_implied_type(obj: &Object, label: &str) -> Result<CtyType, SchemaError> {
54    let mut attrs = BTreeMap::new();
55    for attr in &obj.attributes {
56        attrs.insert(attr.name.clone(), attribute_type(attr)?);
57    }
58    let inner = CtyType::Object(attrs);
59    let nesting = schema::object::NestingMode::try_from(obj.nesting)
60        .map_err(|_| SchemaError::BadNesting(obj.nesting, label.to_string()))?;
61    Ok(match nesting {
62        schema::object::NestingMode::Single | schema::object::NestingMode::Invalid => inner,
63        schema::object::NestingMode::List => CtyType::list(inner),
64        schema::object::NestingMode::Set => CtyType::set(inner),
65        schema::object::NestingMode::Map => CtyType::map(inner),
66    })
67}
68
69/// A `NestedBlock` → cty type, wrapped per its nesting mode. `Single` /
70/// `Group` imply a bare object; `List` / `Set` / `Map` wrap it.
71fn nested_block_type(nb: &NestedBlock) -> Result<CtyType, SchemaError> {
72    let block = nb
73        .block
74        .as_ref()
75        .ok_or_else(|| SchemaError::EmptyNestedBlock(nb.type_name.clone()))?;
76    let inner = block_implied_type(block)?;
77    let nesting = schema::nested_block::NestingMode::try_from(nb.nesting)
78        .map_err(|_| SchemaError::BadNesting(nb.nesting, nb.type_name.clone()))?;
79    Ok(match nesting {
80        schema::nested_block::NestingMode::Single
81        | schema::nested_block::NestingMode::Group
82        | schema::nested_block::NestingMode::Invalid => inner,
83        schema::nested_block::NestingMode::List => CtyType::list(inner),
84        schema::nested_block::NestingMode::Set => CtyType::set(inner),
85        schema::nested_block::NestingMode::Map => CtyType::map(inner),
86    })
87}
88
89// ── tfplugin5 → tfplugin6 schema bridge ──────────────────────────────
90//
91// tfplugin5's `Schema.Block` is a structural subset of tfplugin6's
92// (attributes lack `nested_type`; everything else is identical, and the
93// `NestingMode` enum values match). SDKv2 providers (github, aws, …)
94// speak tfplugin5, so galho's `github_repository` arrives as a v5 schema.
95// Converting v5 → v6 lets the single [`block_implied_type`] parser serve
96// both protocols.
97
98use magma_protocol::tfplugin5;
99
100/// Convert a tfplugin5 `Block` to the tfplugin6 shape, then derive the
101/// implied cty type via [`block_implied_type`].
102pub fn block5_implied_type(block: &tfplugin5::schema::Block) -> Result<CtyType, SchemaError> {
103    block_implied_type(&block5_to_v6(block))
104}
105
106fn block5_to_v6(b: &tfplugin5::schema::Block) -> Block {
107    Block {
108        version: b.version,
109        attributes: b.attributes.iter().map(attr5_to_v6).collect(),
110        block_types: b.block_types.iter().map(nb5_to_v6).collect(),
111        ..Default::default()
112    }
113}
114
115fn attr5_to_v6(a: &tfplugin5::schema::Attribute) -> Attribute {
116    Attribute {
117        name: a.name.clone(),
118        r#type: a.r#type.clone(),
119        nested_type: None, // v5 attributes have no nested_type
120        ..Default::default()
121    }
122}
123
124fn nb5_to_v6(nb: &tfplugin5::schema::NestedBlock) -> NestedBlock {
125    NestedBlock {
126        type_name: nb.type_name.clone(),
127        block: nb.block.as_ref().map(block5_to_v6),
128        nesting: nb.nesting, // NestingMode i32 values match across v5/v6
129        ..Default::default()
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    /// Encode a go-cty JSON type into the `Attribute.type` bytes form.
138    fn ty_bytes(v: serde_json::Value) -> Vec<u8> {
139        serde_json::to_vec(&v).unwrap()
140    }
141
142    fn attr(name: &str, ty: serde_json::Value) -> Attribute {
143        Attribute {
144            name: name.into(),
145            r#type: ty_bytes(ty),
146            ..Default::default()
147        }
148    }
149
150    fn block(attributes: Vec<Attribute>, block_types: Vec<NestedBlock>) -> Block {
151        Block {
152            attributes,
153            block_types,
154            ..Default::default()
155        }
156    }
157
158    #[test]
159    fn simple_scalar_attributes() {
160        let b = block(
161            vec![
162                attr("name", serde_json::json!("string")),
163                attr("private", serde_json::json!("bool")),
164                attr("retries", serde_json::json!("number")),
165            ],
166            vec![],
167        );
168        let ty = block_implied_type(&b).unwrap();
169        let expected = CtyType::object([
170            ("name".into(), CtyType::String),
171            ("private".into(), CtyType::Bool),
172            ("retries".into(), CtyType::Number),
173        ]);
174        assert_eq!(ty, expected);
175    }
176
177    #[test]
178    fn collection_attribute_types() {
179        let b = block(
180            vec![
181                attr("topics", serde_json::json!(["list", "string"])),
182                attr("labels", serde_json::json!(["map", "string"])),
183            ],
184            vec![],
185        );
186        let ty = block_implied_type(&b).unwrap();
187        let expected = CtyType::object([
188            ("topics".into(), CtyType::list(CtyType::String)),
189            ("labels".into(), CtyType::map(CtyType::String)),
190        ]);
191        assert_eq!(ty, expected);
192    }
193
194    #[test]
195    fn nested_block_list_becomes_list_of_object() {
196        // github_repository's `pages { source { branch=string } }`-shape.
197        let inner = block(vec![attr("branch", serde_json::json!("string"))], vec![]);
198        let nb = NestedBlock {
199            type_name: "pages".into(),
200            block: Some(inner),
201            nesting: schema::nested_block::NestingMode::List as i32,
202            ..Default::default()
203        };
204        let b = block(vec![attr("name", serde_json::json!("string"))], vec![nb]);
205        let ty = block_implied_type(&b).unwrap();
206        let expected = CtyType::object([
207            ("name".into(), CtyType::String),
208            (
209                "pages".into(),
210                CtyType::list(CtyType::object([("branch".into(), CtyType::String)])),
211            ),
212        ]);
213        assert_eq!(ty, expected);
214    }
215
216    #[test]
217    fn nested_block_single_is_bare_object() {
218        let inner = block(vec![attr("id".into(), serde_json::json!("string"))], vec![]);
219        let nb = NestedBlock {
220            type_name: "template".into(),
221            block: Some(inner),
222            nesting: schema::nested_block::NestingMode::Single as i32,
223            ..Default::default()
224        };
225        let b = block(vec![], vec![nb]);
226        let ty = block_implied_type(&b).unwrap();
227        let expected = CtyType::object([(
228            "template".into(),
229            CtyType::object([("id".into(), CtyType::String)]),
230        )]);
231        assert_eq!(ty, expected);
232    }
233
234    #[test]
235    fn nested_type_object_map() {
236        // Attribute with a nested_type Object, MAP nesting.
237        let obj = Object {
238            attributes: vec![attr("v", serde_json::json!("string"))],
239            nesting: schema::object::NestingMode::Map as i32,
240            ..Default::default()
241        };
242        let a = Attribute {
243            name: "entries".into(),
244            nested_type: Some(obj),
245            ..Default::default()
246        };
247        let b = block(vec![a], vec![]);
248        let ty = block_implied_type(&b).unwrap();
249        let expected = CtyType::object([(
250            "entries".into(),
251            CtyType::map(CtyType::object([("v".into(), CtyType::String)])),
252        )]);
253        assert_eq!(ty, expected);
254    }
255
256    #[test]
257    fn attribute_without_type_is_an_error() {
258        let a = Attribute {
259            name: "broken".into(),
260            ..Default::default()
261        };
262        let b = block(vec![a], vec![]);
263        assert!(matches!(
264            block_implied_type(&b),
265            Err(SchemaError::AttributeNoType(_))
266        ));
267    }
268}