1use serde_json::Value;
10use std::collections::{BTreeMap, HashMap};
11
12pub type ParamMap = HashMap<String, ParamType>;
16
17pub(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#[derive(Debug, Clone)]
40pub enum ParamType {
41 Bytes,
43 Integer,
45 Boolean,
47 Unit,
49 UtxoRef,
51 Address,
53 Utxo,
55 AnyAsset,
57 List(Box<ParamType>),
59 Tuple(Vec<ParamType>),
61 Map(Box<ParamType>),
63 Record(BTreeMap<String, ParamType>),
65 Variant(Vec<VariantCase>),
67 Unknown(Value),
69}
70
71#[derive(Debug, Clone)]
73pub struct VariantCase {
74 pub tag: String,
76 pub fields: Box<ParamType>,
78}
79
80impl ParamType {
81 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 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 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 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 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 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 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 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 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}