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::{BTreeMap, 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.
64    Record(BTreeMap<String, ParamType>),
65    /// User-defined tagged union (`oneOf`), externally tagged.
66    Variant(Vec<VariantCase>),
67    /// A schema shape that could not be interpreted; carries the raw schema.
68    Unknown(Value),
69}
70
71/// One case of a [`ParamType::Variant`].
72#[derive(Debug, Clone)]
73pub struct VariantCase {
74    /// The case tag (the single `required` key of the externally-tagged object).
75    pub tag: String,
76    /// The case payload (typically a [`ParamType::Record`]).
77    pub fields: Box<ParamType>,
78}
79
80impl ParamType {
81    /// Maps a built-in core `$ref` to its kind by trailing name, so both the
82    /// canonical `…/tii#/$defs/<Name>` and legacy `…/core#<Name>` forms resolve.
83    fn core_ref_type(reference: &str) -> Option<ParamType> {
84        let name = reference.rsplit(['#', '/']).next().unwrap_or("");
85        match name {
86            "Bytes" => Some(ParamType::Bytes),
87            "Address" => Some(ParamType::Address),
88            "UtxoRef" => Some(ParamType::UtxoRef),
89            "Utxo" => Some(ParamType::Utxo),
90            "AnyAsset" => Some(ParamType::AnyAsset),
91            _ => None,
92        }
93    }
94
95    /// Resolves a `$ref` node: `#/components/schemas/<Name>` against the TII's
96    /// `components` table (recursing into the resolved schema), otherwise a
97    /// built-in core ref. An unresolved ref becomes [`ParamType::Unknown`].
98    fn ref_type(schema: &Value, reference: &str, components: &HashMap<String, Value>) -> ParamType {
99        if let Some(name) = reference.strip_prefix("#/components/schemas/") {
100            return match components.get(name) {
101                Some(resolved) => Self::from_json_schema(resolved, components),
102                None => ParamType::Unknown(schema.clone()),
103            };
104        }
105
106        Self::core_ref_type(reference).unwrap_or_else(|| ParamType::Unknown(schema.clone()))
107    }
108
109    /// Maps a `oneOf` array to a [`ParamType::Variant`] of externally-tagged cases.
110    fn variant_type(cases: &[Value], components: &HashMap<String, Value>) -> ParamType {
111        ParamType::Variant(
112            cases
113                .iter()
114                .map(|case| Self::variant_case(case, components))
115                .collect(),
116        )
117    }
118
119    /// Interprets one externally-tagged `oneOf` branch into a [`VariantCase`].
120    fn variant_case(case: &Value, components: &HashMap<String, Value>) -> VariantCase {
121        let tag = case
122            .get("required")
123            .and_then(Value::as_array)
124            .and_then(|r| r.first())
125            .and_then(Value::as_str)
126            .unwrap_or_default()
127            .to_string();
128
129        let fields = case
130            .get("properties")
131            .and_then(Value::as_object)
132            .and_then(|props| props.get(&tag))
133            .map(|fields| Self::from_json_schema(fields, components))
134            .unwrap_or_else(|| ParamType::Unknown(case.clone()));
135
136        VariantCase {
137            tag,
138            fields: Box::new(fields),
139        }
140    }
141
142    /// Maps an `array` schema: `prefixItems` → [`ParamType::Tuple`], `items` →
143    /// [`ParamType::List`]. An array carrying neither becomes [`ParamType::Unknown`].
144    fn array_type(schema: &Value, components: &HashMap<String, Value>) -> ParamType {
145        if let Some(prefix) = schema.get("prefixItems").and_then(Value::as_array) {
146            ParamType::Tuple(
147                prefix
148                    .iter()
149                    .map(|el| Self::from_json_schema(el, components))
150                    .collect(),
151            )
152        } else if let Some(items) = schema.get("items").filter(|i| i.is_object()) {
153            ParamType::List(Box::new(Self::from_json_schema(items, components)))
154        } else {
155            ParamType::Unknown(schema.clone())
156        }
157    }
158
159    /// Maps an `object` schema: `additionalProperties` → [`ParamType::Map`],
160    /// `properties` → [`ParamType::Record`]. Neither present → [`ParamType::Unknown`].
161    fn object_type(schema: &Value, components: &HashMap<String, Value>) -> ParamType {
162        if let Some(value) = schema.get("additionalProperties").filter(|v| v.is_object()) {
163            ParamType::Map(Box::new(Self::from_json_schema(value, components)))
164        } else if let Some(props) = schema.get("properties").and_then(Value::as_object) {
165            ParamType::Record(
166                props
167                    .iter()
168                    .map(|(k, v)| (k.clone(), Self::from_json_schema(v, components)))
169                    .collect(),
170            )
171        } else {
172            ParamType::Unknown(schema.clone())
173        }
174    }
175
176    /// Creates a parameter type from a JSON schema node.
177    ///
178    /// Interprets every shape `tx3c` can emit (see the SDK spec's
179    /// `api-surface/args.md`). It never fails: an unrecognized shape — including a
180    /// bare `string`, an unresolved object, or an unknown `$ref` — becomes
181    /// [`ParamType::Unknown`] carrying the raw schema.
182    ///
183    /// # Arguments
184    ///
185    /// * `schema` - The JSON schema node to interpret
186    /// * `components` - The TII's `components.schemas` table, used to resolve
187    ///   `#/components/schemas/<Name>` references to user-defined types
188    pub fn from_json_schema(schema: &Value, components: &HashMap<String, Value>) -> ParamType {
189        let Some(obj) = schema.as_object() else {
190            return ParamType::Unknown(schema.clone());
191        };
192
193        if let Some(reference) = obj.get("$ref").and_then(Value::as_str) {
194            return Self::ref_type(schema, reference, components);
195        }
196
197        if let Some(cases) = obj.get("oneOf").and_then(Value::as_array) {
198            return Self::variant_type(cases, components);
199        }
200
201        match obj.get("type").and_then(Value::as_str) {
202            Some("integer") => ParamType::Integer,
203            Some("boolean") => ParamType::Boolean,
204            Some("null") => ParamType::Unit,
205            Some("array") => Self::array_type(schema, components),
206            Some("object") => Self::object_type(schema, components),
207            _ => ParamType::Unknown(schema.clone()),
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use serde_json::json;
216
217    fn pt(schema: serde_json::Value) -> ParamType {
218        ParamType::from_json_schema(&schema, &HashMap::new())
219    }
220
221    #[test]
222    fn maps_primitives_and_unit() {
223        assert!(matches!(pt(json!({"type": "integer"})), ParamType::Integer));
224        assert!(matches!(pt(json!({"type": "boolean"})), ParamType::Boolean));
225        assert!(matches!(pt(json!({"type": "null"})), ParamType::Unit));
226    }
227
228    #[test]
229    fn maps_core_refs_in_both_url_forms() {
230        for prefix in [
231            "https://tx3.land/specs/v1beta0/tii#/$defs",
232            "https://tx3.land/specs/v1beta0/core#",
233        ] {
234            // the legacy form has no trailing slash before the name; the canonical
235            // form does — the trailing-name matcher handles both.
236            let join = |name: &str| {
237                if prefix.ends_with('#') {
238                    format!("{prefix}{name}")
239                } else {
240                    format!("{prefix}/{name}")
241                }
242            };
243            assert!(matches!(pt(json!({"$ref": join("Bytes")})), ParamType::Bytes));
244            assert!(matches!(
245                pt(json!({"$ref": join("Address")})),
246                ParamType::Address
247            ));
248            assert!(matches!(
249                pt(json!({"$ref": join("UtxoRef")})),
250                ParamType::UtxoRef
251            ));
252            assert!(matches!(pt(json!({"$ref": join("Utxo")})), ParamType::Utxo));
253            assert!(matches!(
254                pt(json!({"$ref": join("AnyAsset")})),
255                ParamType::AnyAsset
256            ));
257        }
258    }
259
260    #[test]
261    fn maps_list_and_nested_list() {
262        match pt(json!({"type": "array", "items": {"type": "integer"}})) {
263            ParamType::List(inner) => assert!(matches!(*inner, ParamType::Integer)),
264            other => panic!("expected list, got {other:?}"),
265        }
266        match pt(json!({"type": "array", "items": {"type": "array", "items": {"type": "boolean"}}})) {
267            ParamType::List(inner) => match *inner {
268                ParamType::List(deep) => assert!(matches!(*deep, ParamType::Boolean)),
269                other => panic!("expected list(list), got {other:?}"),
270            },
271            other => panic!("expected list, got {other:?}"),
272        }
273    }
274
275    #[test]
276    fn maps_tuple_with_prefix_items() {
277        let schema = json!({
278            "type": "array",
279            "prefixItems": [
280                {"type": "integer"},
281                {"$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes"}
282            ],
283            "items": false
284        });
285        match pt(schema) {
286            ParamType::Tuple(els) => {
287                assert_eq!(els.len(), 2);
288                assert!(matches!(els[0], ParamType::Integer));
289                assert!(matches!(els[1], ParamType::Bytes));
290            }
291            other => panic!("expected tuple, got {other:?}"),
292        }
293    }
294
295    #[test]
296    fn maps_map_via_additional_properties() {
297        match pt(json!({"type": "object", "additionalProperties": {"type": "integer"}})) {
298            ParamType::Map(value) => assert!(matches!(*value, ParamType::Integer)),
299            other => panic!("expected map, got {other:?}"),
300        }
301    }
302
303    #[test]
304    fn maps_record_via_properties() {
305        let schema = json!({
306            "type": "object",
307            "properties": {"price": {"type": "integer"}, "live": {"type": "boolean"}},
308            "required": ["price", "live"]
309        });
310        match pt(schema) {
311            ParamType::Record(fields) => {
312                assert!(matches!(fields["price"], ParamType::Integer));
313                assert!(matches!(fields["live"], ParamType::Boolean));
314            }
315            other => panic!("expected record, got {other:?}"),
316        }
317    }
318
319    #[test]
320    fn maps_variant_via_one_of() {
321        let schema = json!({
322            "oneOf": [
323                {"type": "object", "additionalProperties": false, "required": ["Buy"],
324                 "properties": {"Buy": {"type": "object", "properties": {}, "required": []}}},
325                {"type": "object", "additionalProperties": false, "required": ["Sell"],
326                 "properties": {"Sell": {"type": "object", "properties": {"price": {"type": "integer"}}, "required": ["price"]}}}
327            ]
328        });
329        match pt(schema) {
330            ParamType::Variant(cases) => {
331                assert_eq!(cases.len(), 2);
332                assert_eq!(cases[0].tag, "Buy");
333                assert_eq!(cases[1].tag, "Sell");
334                match &*cases[1].fields {
335                    ParamType::Record(fields) => {
336                        assert!(matches!(fields["price"], ParamType::Integer))
337                    }
338                    other => panic!("expected record fields, got {other:?}"),
339                }
340            }
341            other => panic!("expected variant, got {other:?}"),
342        }
343    }
344
345    #[test]
346    fn resolves_component_refs_recursively() {
347        let mut components = HashMap::new();
348        components.insert(
349            "AssetClass".to_string(),
350            json!({
351                "type": "object",
352                "properties": {"policy": {"$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes"}},
353                "required": ["policy"]
354            }),
355        );
356        let schema = json!({"$ref": "#/components/schemas/AssetClass"});
357        match ParamType::from_json_schema(&schema, &components) {
358            ParamType::Record(fields) => assert!(matches!(fields["policy"], ParamType::Bytes)),
359            other => panic!("expected record, got {other:?}"),
360        }
361        // Missing component → Unknown, never panics.
362        let missing = json!({"$ref": "#/components/schemas/Nope"});
363        assert!(matches!(
364            ParamType::from_json_schema(&missing, &components),
365            ParamType::Unknown(_)
366        ));
367    }
368
369    #[test]
370    fn unrecognized_shapes_fall_back_to_unknown() {
371        assert!(matches!(pt(json!({"type": "string"})), ParamType::Unknown(_)));
372        assert!(matches!(pt(json!({})), ParamType::Unknown(_)));
373        assert!(matches!(pt(json!("nonsense")), ParamType::Unknown(_)));
374        assert!(matches!(
375            pt(json!({"$ref": "https://example.com/Weird"})),
376            ParamType::Unknown(_)
377        ));
378        assert!(matches!(
379            pt(json!({"type": "array"})),
380            ParamType::Unknown(_)
381        ));
382    }
383}