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