Skip to main content

tx3_sdk/tii/
schema.rs

1//! Interpretation of the TII params JSON schema into [`ParamType`] kinds.
2//!
3//! A TII embeds, for each transaction, a JSON schema describing its parameters
4//! (and an optional environment schema). This module turns those schema nodes —
5//! every shape `tx3c` can emit, see the SDK spec's `api-surface/args.md` — into
6//! the [`ParamType`] model the rest of the SDK works with. Interpretation never
7//! fails: any shape it does not recognize becomes [`ParamType::Unknown`].
8
9use serde_json::Value;
10use std::collections::HashMap;
11
12/// Map of parameter names to their types.
13///
14/// Used to represent the complete set of parameters required for a transaction.
15pub type ParamMap = HashMap<String, ParamType>;
16
17/// Builds a parameter-type map from a JSON schema's `properties`. Never fails:
18/// unrecognized property schemas yield [`ParamType::Unknown`]. `components` is the
19/// TII's `components.schemas` table, used to resolve `#/components/schemas/<Name>`
20/// refs to user-defined record / variant types.
21pub(super) fn params_from_schema(schema: &Value, components: &HashMap<String, Value>) -> ParamMap {
22    let mut params = ParamMap::new();
23
24    if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
25        for (key, value) in properties {
26            params.insert(key.clone(), ParamType::from_json_schema(value, components));
27        }
28    }
29
30    params
31}
32
33/// Type of a transaction parameter.
34///
35/// This enum represents the various types that transaction parameters can have,
36/// including primitives, compound types, and references to TX3 core types. It is
37/// built from the TII params JSON schema by [`ParamType::from_json_schema`], which
38/// never fails — any shape it does not recognize becomes [`ParamType::Unknown`].
39#[derive(Debug, Clone)]
40pub enum ParamType {
41    /// Byte array type (hex-encoded).
42    Bytes,
43    /// Integer type (signed or unsigned).
44    Integer,
45    /// Boolean type.
46    Boolean,
47    /// Unit type (`{ "type": "null" }`).
48    Unit,
49    /// UTXO reference in format `0x[64hex]#[index]`.
50    UtxoRef,
51    /// Bech32-encoded blockchain address.
52    Address,
53    /// A resolved UTxO object.
54    Utxo,
55    /// An asset identified at runtime by policy and name.
56    AnyAsset,
57    /// Homogeneous, variable-length sequence (`array` + `items`).
58    List(Box<ParamType>),
59    /// Fixed-length, positionally-typed sequence (`array` + `prefixItems`).
60    Tuple(Vec<ParamType>),
61    /// String-keyed homogeneous map (`object` + `additionalProperties`).
62    Map(Box<ParamType>),
63    /// User-defined record (`object` + `properties`), `(field name, type)` in
64    /// **declared order** (the schema's `required` array, which `tx3c` emits in
65    /// source order — `properties` is alphabetized and must not drive field
66    /// order). Encoding maps the user's by-name object to positional fields.
67    Record(Vec<(String, ParamType)>),
68    /// User-defined tagged union (`oneOf`), externally tagged.
69    Variant(Vec<VariantCase>),
70    /// A schema shape that could not be interpreted; carries the raw schema.
71    Unknown(Value),
72}
73
74/// One case of a [`ParamType::Variant`].
75#[derive(Debug, Clone)]
76pub struct VariantCase {
77    /// The case tag (the single `required` key of the externally-tagged object).
78    pub tag: String,
79    /// The case payload (typically a [`ParamType::Record`]).
80    pub fields: Box<ParamType>,
81}
82
83impl ParamType {
84    /// Looks up a field type by name in a [`ParamType::Record`]; `None` for any
85    /// other kind or an absent field.
86    pub fn field(&self, name: &str) -> Option<&ParamType> {
87        match self {
88            ParamType::Record(fields) => {
89                fields.iter().find(|(k, _)| k == name).map(|(_, ty)| ty)
90            }
91            _ => None,
92        }
93    }
94
95    /// Maps a built-in core `$ref` to its kind by trailing name, so both the
96    /// canonical `…/tii#/$defs/<Name>` and legacy `…/core#<Name>` forms resolve.
97    fn core_ref_type(reference: &str) -> Option<ParamType> {
98        let name = reference.rsplit(['#', '/']).next().unwrap_or("");
99        match name {
100            "Bytes" => Some(ParamType::Bytes),
101            "Address" => Some(ParamType::Address),
102            "UtxoRef" => Some(ParamType::UtxoRef),
103            "Utxo" => Some(ParamType::Utxo),
104            "AnyAsset" => Some(ParamType::AnyAsset),
105            _ => None,
106        }
107    }
108
109    /// Resolves a `$ref` node: `#/components/schemas/<Name>` against the TII's
110    /// `components` table (recursing into the resolved schema), otherwise a
111    /// built-in core ref. An unresolved ref becomes [`ParamType::Unknown`].
112    fn ref_type(schema: &Value, reference: &str, components: &HashMap<String, Value>) -> ParamType {
113        if let Some(name) = reference.strip_prefix("#/components/schemas/") {
114            return match components.get(name) {
115                Some(resolved) => Self::from_json_schema(resolved, components),
116                None => ParamType::Unknown(schema.clone()),
117            };
118        }
119
120        Self::core_ref_type(reference).unwrap_or_else(|| ParamType::Unknown(schema.clone()))
121    }
122
123    /// Maps a `oneOf` array to a [`ParamType::Variant`] of externally-tagged cases.
124    fn variant_type(cases: &[Value], components: &HashMap<String, Value>) -> ParamType {
125        ParamType::Variant(
126            cases
127                .iter()
128                .map(|case| Self::variant_case(case, components))
129                .collect(),
130        )
131    }
132
133    /// Interprets one externally-tagged `oneOf` branch into a [`VariantCase`].
134    fn variant_case(case: &Value, components: &HashMap<String, Value>) -> VariantCase {
135        let tag = case
136            .get("required")
137            .and_then(Value::as_array)
138            .and_then(|r| r.first())
139            .and_then(Value::as_str)
140            .unwrap_or_default()
141            .to_string();
142
143        let fields = case
144            .get("properties")
145            .and_then(Value::as_object)
146            .and_then(|props| props.get(&tag))
147            .map(|fields| Self::from_json_schema(fields, components))
148            .unwrap_or_else(|| ParamType::Unknown(case.clone()));
149
150        VariantCase {
151            tag,
152            fields: Box::new(fields),
153        }
154    }
155
156    /// Maps an `array` schema: `prefixItems` → [`ParamType::Tuple`], `items` →
157    /// [`ParamType::List`]. An array carrying neither becomes [`ParamType::Unknown`].
158    fn array_type(schema: &Value, components: &HashMap<String, Value>) -> ParamType {
159        if let Some(prefix) = schema.get("prefixItems").and_then(Value::as_array) {
160            ParamType::Tuple(
161                prefix
162                    .iter()
163                    .map(|el| Self::from_json_schema(el, components))
164                    .collect(),
165            )
166        } else if let Some(items) = schema.get("items").filter(|i| i.is_object()) {
167            ParamType::List(Box::new(Self::from_json_schema(items, components)))
168        } else {
169            ParamType::Unknown(schema.clone())
170        }
171    }
172
173    /// Maps an `object` schema: `additionalProperties` → [`ParamType::Map`],
174    /// `properties` → [`ParamType::Record`]. Neither present → [`ParamType::Unknown`].
175    fn object_type(schema: &Value, components: &HashMap<String, Value>) -> ParamType {
176        if let Some(value) = schema.get("additionalProperties").filter(|v| v.is_object()) {
177            ParamType::Map(Box::new(Self::from_json_schema(value, components)))
178        } else if let Some(props) = schema.get("properties").and_then(Value::as_object) {
179            ParamType::Record(Self::record_fields(schema, props, components))
180        } else {
181            ParamType::Unknown(schema.clone())
182        }
183    }
184
185    /// Builds record fields in declared order: the `required` array first (source
186    /// order, as `tx3c` emits), then any remaining (alphabetized) `properties`.
187    fn record_fields(
188        schema: &Value,
189        props: &serde_json::Map<String, Value>,
190        components: &HashMap<String, Value>,
191    ) -> Vec<(String, ParamType)> {
192        let mut fields = Vec::with_capacity(props.len());
193        let mut seen = std::collections::HashSet::new();
194
195        if let Some(required) = schema.get("required").and_then(Value::as_array) {
196            for name in required.iter().filter_map(Value::as_str) {
197                if let Some(field_schema) = props.get(name) {
198                    fields.push((name.to_string(), Self::from_json_schema(field_schema, components)));
199                    seen.insert(name.to_string());
200                }
201            }
202        }
203
204        for (k, v) in props {
205            if !seen.contains(k) {
206                fields.push((k.clone(), Self::from_json_schema(v, components)));
207            }
208        }
209
210        fields
211    }
212
213    /// Creates a parameter type from a JSON schema node.
214    ///
215    /// Interprets every shape `tx3c` can emit (see the SDK spec's
216    /// `api-surface/args.md`). It never fails: an unrecognized shape — including a
217    /// bare `string`, an unresolved object, or an unknown `$ref` — becomes
218    /// [`ParamType::Unknown`] carrying the raw schema.
219    ///
220    /// # Arguments
221    ///
222    /// * `schema` - The JSON schema node to interpret
223    /// * `components` - The TII's `components.schemas` table, used to resolve
224    ///   `#/components/schemas/<Name>` references to user-defined types
225    pub fn from_json_schema(schema: &Value, components: &HashMap<String, Value>) -> ParamType {
226        let Some(obj) = schema.as_object() else {
227            return ParamType::Unknown(schema.clone());
228        };
229
230        if let Some(reference) = obj.get("$ref").and_then(Value::as_str) {
231            return Self::ref_type(schema, reference, components);
232        }
233
234        if let Some(cases) = obj.get("oneOf").and_then(Value::as_array) {
235            return Self::variant_type(cases, components);
236        }
237
238        match obj.get("type").and_then(Value::as_str) {
239            Some("integer") => ParamType::Integer,
240            Some("boolean") => ParamType::Boolean,
241            Some("null") => ParamType::Unit,
242            Some("array") => Self::array_type(schema, components),
243            Some("object") => Self::object_type(schema, components),
244            _ => ParamType::Unknown(schema.clone()),
245        }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use serde_json::json;
253
254    fn pt(schema: serde_json::Value) -> ParamType {
255        ParamType::from_json_schema(&schema, &HashMap::new())
256    }
257
258    #[test]
259    fn maps_primitives_and_unit() {
260        assert!(matches!(pt(json!({"type": "integer"})), ParamType::Integer));
261        assert!(matches!(pt(json!({"type": "boolean"})), ParamType::Boolean));
262        assert!(matches!(pt(json!({"type": "null"})), ParamType::Unit));
263    }
264
265    #[test]
266    fn maps_core_refs_in_both_url_forms() {
267        for prefix in [
268            "https://tx3.land/specs/v1beta0/tii#/$defs",
269            "https://tx3.land/specs/v1beta0/core#",
270        ] {
271            // the legacy form has no trailing slash before the name; the canonical
272            // form does — the trailing-name matcher handles both.
273            let join = |name: &str| {
274                if prefix.ends_with('#') {
275                    format!("{prefix}{name}")
276                } else {
277                    format!("{prefix}/{name}")
278                }
279            };
280            assert!(matches!(pt(json!({"$ref": join("Bytes")})), ParamType::Bytes));
281            assert!(matches!(
282                pt(json!({"$ref": join("Address")})),
283                ParamType::Address
284            ));
285            assert!(matches!(
286                pt(json!({"$ref": join("UtxoRef")})),
287                ParamType::UtxoRef
288            ));
289            assert!(matches!(pt(json!({"$ref": join("Utxo")})), ParamType::Utxo));
290            assert!(matches!(
291                pt(json!({"$ref": join("AnyAsset")})),
292                ParamType::AnyAsset
293            ));
294        }
295    }
296
297    #[test]
298    fn maps_list_and_nested_list() {
299        match pt(json!({"type": "array", "items": {"type": "integer"}})) {
300            ParamType::List(inner) => assert!(matches!(*inner, ParamType::Integer)),
301            other => panic!("expected list, got {other:?}"),
302        }
303        match pt(json!({"type": "array", "items": {"type": "array", "items": {"type": "boolean"}}})) {
304            ParamType::List(inner) => match *inner {
305                ParamType::List(deep) => assert!(matches!(*deep, ParamType::Boolean)),
306                other => panic!("expected list(list), got {other:?}"),
307            },
308            other => panic!("expected list, got {other:?}"),
309        }
310    }
311
312    #[test]
313    fn maps_tuple_with_prefix_items() {
314        let schema = json!({
315            "type": "array",
316            "prefixItems": [
317                {"type": "integer"},
318                {"$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes"}
319            ],
320            "items": false
321        });
322        match pt(schema) {
323            ParamType::Tuple(els) => {
324                assert_eq!(els.len(), 2);
325                assert!(matches!(els[0], ParamType::Integer));
326                assert!(matches!(els[1], ParamType::Bytes));
327            }
328            other => panic!("expected tuple, got {other:?}"),
329        }
330    }
331
332    #[test]
333    fn maps_map_via_additional_properties() {
334        match pt(json!({"type": "object", "additionalProperties": {"type": "integer"}})) {
335            ParamType::Map(value) => assert!(matches!(*value, ParamType::Integer)),
336            other => panic!("expected map, got {other:?}"),
337        }
338    }
339
340    #[test]
341    fn maps_record_via_properties() {
342        let schema = json!({
343            "type": "object",
344            "properties": {"price": {"type": "integer"}, "live": {"type": "boolean"}},
345            "required": ["price", "live"]
346        });
347        match pt(schema) {
348            rec @ ParamType::Record(_) => {
349                assert!(matches!(rec.field("price"), Some(ParamType::Integer)));
350                assert!(matches!(rec.field("live"), Some(ParamType::Boolean)));
351            }
352            other => panic!("expected record, got {other:?}"),
353        }
354    }
355
356    #[test]
357    fn maps_variant_via_one_of() {
358        let schema = json!({
359            "oneOf": [
360                {"type": "object", "additionalProperties": false, "required": ["Buy"],
361                 "properties": {"Buy": {"type": "object", "properties": {}, "required": []}}},
362                {"type": "object", "additionalProperties": false, "required": ["Sell"],
363                 "properties": {"Sell": {"type": "object", "properties": {"price": {"type": "integer"}}, "required": ["price"]}}}
364            ]
365        });
366        match pt(schema) {
367            ParamType::Variant(cases) => {
368                assert_eq!(cases.len(), 2);
369                assert_eq!(cases[0].tag, "Buy");
370                assert_eq!(cases[1].tag, "Sell");
371                let sell_fields = &*cases[1].fields;
372                assert!(matches!(sell_fields, ParamType::Record(_)));
373                assert!(matches!(
374                    sell_fields.field("price"),
375                    Some(ParamType::Integer)
376                ));
377            }
378            other => panic!("expected variant, got {other:?}"),
379        }
380    }
381
382    #[test]
383    fn resolves_component_refs_recursively() {
384        let mut components = HashMap::new();
385        components.insert(
386            "AssetClass".to_string(),
387            json!({
388                "type": "object",
389                "properties": {"policy": {"$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes"}},
390                "required": ["policy"]
391            }),
392        );
393        let schema = json!({"$ref": "#/components/schemas/AssetClass"});
394        match ParamType::from_json_schema(&schema, &components) {
395            rec @ ParamType::Record(_) => assert!(matches!(rec.field("policy"), Some(ParamType::Bytes))),
396            other => panic!("expected record, got {other:?}"),
397        }
398        // Missing component → Unknown, never panics.
399        let missing = json!({"$ref": "#/components/schemas/Nope"});
400        assert!(matches!(
401            ParamType::from_json_schema(&missing, &components),
402            ParamType::Unknown(_)
403        ));
404    }
405
406    #[test]
407    fn unrecognized_shapes_fall_back_to_unknown() {
408        assert!(matches!(pt(json!({"type": "string"})), ParamType::Unknown(_)));
409        assert!(matches!(pt(json!({})), ParamType::Unknown(_)));
410        assert!(matches!(pt(json!("nonsense")), ParamType::Unknown(_)));
411        assert!(matches!(
412            pt(json!({"$ref": "https://example.com/Weird"})),
413            ParamType::Unknown(_)
414        ));
415        assert!(matches!(
416            pt(json!({"type": "array"})),
417            ParamType::Unknown(_)
418        ));
419    }
420}