1use serde_json::Value;
10use std::collections::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(Vec<(String, ParamType)>),
68 Variant(Vec<VariantCase>),
70 Unknown(Value),
72}
73
74#[derive(Debug, Clone)]
76pub struct VariantCase {
77 pub tag: String,
79 pub fields: Box<ParamType>,
81}
82
83impl ParamType {
84 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 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 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 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 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 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 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 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 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 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 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}