Skip to main content

concinnity_cook/
asset_api.rs

1//! Shared asset construction API.
2//!
3//! This module is the single place where "type name + JSON args → BlobAssetDef"
4//! is implemented.
5use crate::ecs::{AssetKind, AssetOrigin, BlobAssetDef};
6use crate::registry::RegisteredType;
7use crate::registry::Registration;
8use crate::result::CnResult;
9
10/// Incoming request to construct an asset from an external caller
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
12pub struct AssetRequest {
13    /// type name as it appears in the world declaration ("Mesh", "Material", ...)
14    /// case-insensitive; underscores ignored
15    pub asset_type: String,
16    /// constructor args. If None, the type's default_args are used
17    #[serde(default)]
18    pub args: Option<serde_json::Value>,
19}
20
21/// Validate an AssetRequest and produce a BlobAssetDef
22///
23/// Returns Err if:
24/// - The type name is unknown
25/// - The type's origin is not External (not addable)
26/// - The resolved args cannot be serialized
27///
28/// Does not perform payload compilation (shaders, images, etc.). The build
29/// step calls this first, then runs its compilation pass over the resulting
30/// defs. The HTTP API follows the same two-step pattern
31pub fn create_asset_def(req: &AssetRequest) -> Result<BlobAssetDef, CnResult> {
32    if let Some(ct) = RegisteredType::parse(&req.asset_type) {
33        let reg = ct.registration();
34        if reg.origin != AssetOrigin::External {
35            return Err(CnResult::InvalidArgument);
36        }
37        // A resource asset is External too, but compiles into the resource
38        // stream rather than a component record, so it has no tag to carry.
39        let discriminant = ct.discriminant().ok_or(CnResult::InvalidArgument)?;
40        let args = resolve_args(&reg, &req.args);
41        // Every record is baked. For a pass-through type the baked component is
42        // its reserialized args (the component IS its args); a divergent type
43        // (`Args != Self`) bakes the translated component instead.
44        let args_bytes = match crate::registry::bake_divergent(ct, &args)? {
45            Some(bytes) => bytes,
46            None => ct.reserialize_args(&args)?,
47        };
48        return Ok(BlobAssetDef {
49            name: None,
50            kind: AssetKind::Component,
51            discriminant,
52            args_bytes,
53            payload: None,
54        });
55    }
56
57    tracing::error!("asset_api: unknown asset type '{}'", req.asset_type);
58    Err(CnResult::AssetInvalidType)
59}
60
61// Resolve the args to use for construction.
62//
63// Merges supplied args over the type's defaults so that missing keys are filled
64// in automatically. This lets callers supply partial args (including `{}`) and
65// still get sensible values for any fields they omit.
66fn resolve_args(reg: &Registration, supplied: &Option<serde_json::Value>) -> serde_json::Value {
67    let mut base = reg
68        .default_args
69        .clone()
70        .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
71
72    if let Some(serde_json::Value::Object(supplied_map)) = supplied {
73        if let serde_json::Value::Object(ref mut base_map) = base {
74            for (k, v) in supplied_map {
75                base_map.insert(k.clone(), v.clone());
76            }
77        }
78    } else if let Some(v) = supplied {
79        base = v.clone();
80    }
81
82    base
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    fn shader_reg() -> Registration {
90        Registration {
91            type_name: "VertexStage",
92            origin: AssetOrigin::External,
93            payload: crate::ecs::AssetPayload::Compiled,
94            default_args: Some(serde_json::json!({ "source": "user.metal" })),
95        }
96    }
97
98    #[test]
99    fn resolve_args_none_uses_default() {
100        let reg = shader_reg();
101        let result = resolve_args(&reg, &None);
102        assert_eq!(result["source"], "user.metal");
103    }
104
105    #[test]
106    fn resolve_args_empty_object_fills_from_default() {
107        let reg = shader_reg();
108        let supplied = Some(serde_json::json!({}));
109        let result = resolve_args(&reg, &supplied);
110        assert_eq!(result["source"], "user.metal");
111    }
112
113    #[test]
114    fn resolve_args_supplied_value_wins() {
115        let reg = shader_reg();
116        let supplied = Some(serde_json::json!({ "source": "custom.metal" }));
117        let result = resolve_args(&reg, &supplied);
118        assert_eq!(result["source"], "custom.metal");
119    }
120
121    #[test]
122    fn resolve_args_partial_keeps_default_for_missing_keys() {
123        let reg = Registration {
124            type_name: "Fake",
125            origin: AssetOrigin::External,
126            payload: crate::ecs::AssetPayload::None,
127            default_args: Some(serde_json::json!({ "a": 1, "b": 2 })),
128        };
129        let supplied = Some(serde_json::json!({ "b": 99 }));
130        let result = resolve_args(&reg, &supplied);
131        assert_eq!(result["a"], 1);
132        assert_eq!(result["b"], 99);
133    }
134
135    #[test]
136    fn resolve_args_non_object_supplied_replaces_the_default() {
137        let reg = shader_reg();
138        let supplied = Some(serde_json::json!([1, 2, 3]));
139        let result = resolve_args(&reg, &supplied);
140        assert_eq!(result, serde_json::json!([1, 2, 3]));
141    }
142
143    // Merging is only defined between two objects: supplied args cannot be
144    // folded into a non-object default, so the default is kept as-is.
145    #[test]
146    fn resolve_args_keeps_a_non_object_default_when_an_object_is_supplied() {
147        let reg = Registration {
148            type_name: "Fake",
149            origin: AssetOrigin::External,
150            payload: crate::ecs::AssetPayload::None,
151            default_args: Some(serde_json::json!([1, 2, 3])),
152        };
153        let supplied = Some(serde_json::json!({ "a": 1 }));
154        assert_eq!(resolve_args(&reg, &supplied), serde_json::json!([1, 2, 3]));
155    }
156
157    #[test]
158    fn create_asset_def_rejects_unknown_types() {
159        let req = AssetRequest {
160            asset_type: "NotARealAsset".to_string(),
161            args: None,
162        };
163        assert_eq!(
164            create_asset_def(&req).unwrap_err(),
165            CnResult::AssetInvalidType
166        );
167    }
168
169    #[test]
170    fn create_asset_def_rejects_runtime_only_types() {
171        // Transform is registered but RuntimeOnly, so it is not addable.
172        let req = AssetRequest {
173            asset_type: "Transform".to_string(),
174            args: None,
175        };
176        assert_eq!(
177            create_asset_def(&req).unwrap_err(),
178            CnResult::InvalidArgument
179        );
180    }
181
182    #[test]
183    fn create_asset_def_builds_a_component_def() {
184        let req = AssetRequest {
185            asset_type: "ProceduralMesh".to_string(),
186            args: None,
187        };
188        let def = create_asset_def(&req).unwrap();
189        assert_eq!(def.kind, AssetKind::Component);
190        assert_eq!(
191            Some(def.discriminant),
192            RegisteredType::parse("ProceduralMesh")
193                .unwrap()
194                .discriminant()
195        );
196        assert!(def.name.is_none());
197        assert!(def.payload.is_none());
198        // The baked bytes decode as the component (postcard).
199        postcard::from_bytes::<crate::components::ProceduralMesh>(&def.args_bytes).unwrap();
200    }
201
202    // Every addable component type builds a baked def from its default args: the
203    // def's bytes reconstruct through `from_baked` at load. A new type whose
204    // baked form cannot round-trip its defaults fails here. A resource asset has
205    // no component def; it compiles into the resource stream instead.
206    #[test]
207    fn every_addable_component_type_builds_a_baked_def() {
208        for (ct, _) in RegisteredType::addable_types().filter(|(t, _)| !t.is_resource()) {
209            let def = create_asset_def(&AssetRequest {
210                asset_type: ct.as_str().to_string(),
211                args: None,
212            })
213            .unwrap();
214            assert_eq!(def.kind, AssetKind::Component, "{}", ct.as_str());
215            assert!(!def.args_bytes.is_empty(), "{}", ct.as_str());
216        }
217    }
218
219    #[test]
220    fn create_asset_def_merges_supplied_args_over_defaults() {
221        let req = AssetRequest {
222            asset_type: "ProceduralMesh".to_string(),
223            args: Some(serde_json::json!({ "generator": "box" })),
224        };
225        let def = create_asset_def(&req).unwrap();
226        let baked: crate::components::ProceduralMesh =
227            postcard::from_bytes(&def.args_bytes).unwrap();
228        assert_eq!(baked.generator, "box");
229        // Defaults fill the fields the caller omitted.
230        let defaults = crate::components::ProceduralMesh::default();
231        assert_eq!(baked.half_width, defaults.half_width);
232        assert_eq!(baked.ceiling_height, defaults.ceiling_height);
233    }
234
235    #[test]
236    fn create_asset_def_rejects_mistyped_args() {
237        let req = AssetRequest {
238            asset_type: "ProceduralMesh".to_string(),
239            args: Some(serde_json::json!({ "generator": 42 })),
240        };
241        assert_eq!(
242            create_asset_def(&req).unwrap_err(),
243            CnResult::InvalidArgument
244        );
245    }
246
247    // Every addable type is authorable, and only the authorable ones are
248    // addable. Order is the registry's, which is the discriminant order, not
249    // alphabetical.
250    #[test]
251    fn addable_types_are_external_only() {
252        let names: Vec<&str> = RegisteredType::addable_types()
253            .inspect(|(_, reg)| assert_eq!(reg.origin, AssetOrigin::External))
254            .map(|(ct, _)| ct.as_str())
255            .collect();
256        assert!(!names.is_empty());
257        assert!(names.contains(&"ProceduralMesh"));
258        assert!(!names.contains(&"Transform"));
259    }
260}