use serde_json::Value;
use std::collections::HashMap;
pub type ParamMap = HashMap<String, ParamType>;
pub(super) fn params_from_schema(schema: &Value, components: &HashMap<String, Value>) -> ParamMap {
let mut params = ParamMap::new();
if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
for (key, value) in properties {
params.insert(key.clone(), ParamType::from_json_schema(value, components));
}
}
params
}
#[derive(Debug, Clone)]
pub enum ParamType {
Bytes,
Integer,
Boolean,
Unit,
UtxoRef,
Address,
Utxo,
AnyAsset,
List(Box<ParamType>),
Tuple(Vec<ParamType>),
Map(Box<ParamType>),
Record(Vec<(String, ParamType)>),
Variant(Vec<VariantCase>),
Unknown(Value),
}
#[derive(Debug, Clone)]
pub struct VariantCase {
pub tag: String,
pub fields: Box<ParamType>,
}
impl ParamType {
pub fn field(&self, name: &str) -> Option<&ParamType> {
match self {
ParamType::Record(fields) => {
fields.iter().find(|(k, _)| k == name).map(|(_, ty)| ty)
}
_ => None,
}
}
fn core_ref_type(reference: &str) -> Option<ParamType> {
let name = reference.rsplit(['#', '/']).next().unwrap_or("");
match name {
"Bytes" => Some(ParamType::Bytes),
"Address" => Some(ParamType::Address),
"UtxoRef" => Some(ParamType::UtxoRef),
"Utxo" => Some(ParamType::Utxo),
"AnyAsset" => Some(ParamType::AnyAsset),
_ => None,
}
}
fn ref_type(schema: &Value, reference: &str, components: &HashMap<String, Value>) -> ParamType {
if let Some(name) = reference.strip_prefix("#/components/schemas/") {
return match components.get(name) {
Some(resolved) => Self::from_json_schema(resolved, components),
None => ParamType::Unknown(schema.clone()),
};
}
Self::core_ref_type(reference).unwrap_or_else(|| ParamType::Unknown(schema.clone()))
}
fn variant_type(cases: &[Value], components: &HashMap<String, Value>) -> ParamType {
ParamType::Variant(
cases
.iter()
.map(|case| Self::variant_case(case, components))
.collect(),
)
}
fn variant_case(case: &Value, components: &HashMap<String, Value>) -> VariantCase {
let tag = case
.get("required")
.and_then(Value::as_array)
.and_then(|r| r.first())
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let fields = case
.get("properties")
.and_then(Value::as_object)
.and_then(|props| props.get(&tag))
.map(|fields| Self::from_json_schema(fields, components))
.unwrap_or_else(|| ParamType::Unknown(case.clone()));
VariantCase {
tag,
fields: Box::new(fields),
}
}
fn array_type(schema: &Value, components: &HashMap<String, Value>) -> ParamType {
if let Some(prefix) = schema.get("prefixItems").and_then(Value::as_array) {
ParamType::Tuple(
prefix
.iter()
.map(|el| Self::from_json_schema(el, components))
.collect(),
)
} else if let Some(items) = schema.get("items").filter(|i| i.is_object()) {
ParamType::List(Box::new(Self::from_json_schema(items, components)))
} else {
ParamType::Unknown(schema.clone())
}
}
fn object_type(schema: &Value, components: &HashMap<String, Value>) -> ParamType {
if let Some(value) = schema.get("additionalProperties").filter(|v| v.is_object()) {
ParamType::Map(Box::new(Self::from_json_schema(value, components)))
} else if let Some(props) = schema.get("properties").and_then(Value::as_object) {
ParamType::Record(Self::record_fields(schema, props, components))
} else {
ParamType::Unknown(schema.clone())
}
}
fn record_fields(
schema: &Value,
props: &serde_json::Map<String, Value>,
components: &HashMap<String, Value>,
) -> Vec<(String, ParamType)> {
let mut fields = Vec::with_capacity(props.len());
let mut seen = std::collections::HashSet::new();
if let Some(required) = schema.get("required").and_then(Value::as_array) {
for name in required.iter().filter_map(Value::as_str) {
if let Some(field_schema) = props.get(name) {
fields.push((name.to_string(), Self::from_json_schema(field_schema, components)));
seen.insert(name.to_string());
}
}
}
for (k, v) in props {
if !seen.contains(k) {
fields.push((k.clone(), Self::from_json_schema(v, components)));
}
}
fields
}
pub fn from_json_schema(schema: &Value, components: &HashMap<String, Value>) -> ParamType {
let Some(obj) = schema.as_object() else {
return ParamType::Unknown(schema.clone());
};
if let Some(reference) = obj.get("$ref").and_then(Value::as_str) {
return Self::ref_type(schema, reference, components);
}
if let Some(cases) = obj.get("oneOf").and_then(Value::as_array) {
return Self::variant_type(cases, components);
}
match obj.get("type").and_then(Value::as_str) {
Some("integer") => ParamType::Integer,
Some("boolean") => ParamType::Boolean,
Some("null") => ParamType::Unit,
Some("array") => Self::array_type(schema, components),
Some("object") => Self::object_type(schema, components),
_ => ParamType::Unknown(schema.clone()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn pt(schema: serde_json::Value) -> ParamType {
ParamType::from_json_schema(&schema, &HashMap::new())
}
#[test]
fn maps_primitives_and_unit() {
assert!(matches!(pt(json!({"type": "integer"})), ParamType::Integer));
assert!(matches!(pt(json!({"type": "boolean"})), ParamType::Boolean));
assert!(matches!(pt(json!({"type": "null"})), ParamType::Unit));
}
#[test]
fn maps_core_refs_in_both_url_forms() {
for prefix in [
"https://tx3.land/specs/v1beta0/tii#/$defs",
"https://tx3.land/specs/v1beta0/core#",
] {
let join = |name: &str| {
if prefix.ends_with('#') {
format!("{prefix}{name}")
} else {
format!("{prefix}/{name}")
}
};
assert!(matches!(pt(json!({"$ref": join("Bytes")})), ParamType::Bytes));
assert!(matches!(
pt(json!({"$ref": join("Address")})),
ParamType::Address
));
assert!(matches!(
pt(json!({"$ref": join("UtxoRef")})),
ParamType::UtxoRef
));
assert!(matches!(pt(json!({"$ref": join("Utxo")})), ParamType::Utxo));
assert!(matches!(
pt(json!({"$ref": join("AnyAsset")})),
ParamType::AnyAsset
));
}
}
#[test]
fn maps_list_and_nested_list() {
match pt(json!({"type": "array", "items": {"type": "integer"}})) {
ParamType::List(inner) => assert!(matches!(*inner, ParamType::Integer)),
other => panic!("expected list, got {other:?}"),
}
match pt(json!({"type": "array", "items": {"type": "array", "items": {"type": "boolean"}}})) {
ParamType::List(inner) => match *inner {
ParamType::List(deep) => assert!(matches!(*deep, ParamType::Boolean)),
other => panic!("expected list(list), got {other:?}"),
},
other => panic!("expected list, got {other:?}"),
}
}
#[test]
fn maps_tuple_with_prefix_items() {
let schema = json!({
"type": "array",
"prefixItems": [
{"type": "integer"},
{"$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes"}
],
"items": false
});
match pt(schema) {
ParamType::Tuple(els) => {
assert_eq!(els.len(), 2);
assert!(matches!(els[0], ParamType::Integer));
assert!(matches!(els[1], ParamType::Bytes));
}
other => panic!("expected tuple, got {other:?}"),
}
}
#[test]
fn maps_map_via_additional_properties() {
match pt(json!({"type": "object", "additionalProperties": {"type": "integer"}})) {
ParamType::Map(value) => assert!(matches!(*value, ParamType::Integer)),
other => panic!("expected map, got {other:?}"),
}
}
#[test]
fn maps_record_via_properties() {
let schema = json!({
"type": "object",
"properties": {"price": {"type": "integer"}, "live": {"type": "boolean"}},
"required": ["price", "live"]
});
match pt(schema) {
rec @ ParamType::Record(_) => {
assert!(matches!(rec.field("price"), Some(ParamType::Integer)));
assert!(matches!(rec.field("live"), Some(ParamType::Boolean)));
}
other => panic!("expected record, got {other:?}"),
}
}
#[test]
fn maps_variant_via_one_of() {
let schema = json!({
"oneOf": [
{"type": "object", "additionalProperties": false, "required": ["Buy"],
"properties": {"Buy": {"type": "object", "properties": {}, "required": []}}},
{"type": "object", "additionalProperties": false, "required": ["Sell"],
"properties": {"Sell": {"type": "object", "properties": {"price": {"type": "integer"}}, "required": ["price"]}}}
]
});
match pt(schema) {
ParamType::Variant(cases) => {
assert_eq!(cases.len(), 2);
assert_eq!(cases[0].tag, "Buy");
assert_eq!(cases[1].tag, "Sell");
let sell_fields = &*cases[1].fields;
assert!(matches!(sell_fields, ParamType::Record(_)));
assert!(matches!(
sell_fields.field("price"),
Some(ParamType::Integer)
));
}
other => panic!("expected variant, got {other:?}"),
}
}
#[test]
fn resolves_component_refs_recursively() {
let mut components = HashMap::new();
components.insert(
"AssetClass".to_string(),
json!({
"type": "object",
"properties": {"policy": {"$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes"}},
"required": ["policy"]
}),
);
let schema = json!({"$ref": "#/components/schemas/AssetClass"});
match ParamType::from_json_schema(&schema, &components) {
rec @ ParamType::Record(_) => assert!(matches!(rec.field("policy"), Some(ParamType::Bytes))),
other => panic!("expected record, got {other:?}"),
}
let missing = json!({"$ref": "#/components/schemas/Nope"});
assert!(matches!(
ParamType::from_json_schema(&missing, &components),
ParamType::Unknown(_)
));
}
#[test]
fn unrecognized_shapes_fall_back_to_unknown() {
assert!(matches!(pt(json!({"type": "string"})), ParamType::Unknown(_)));
assert!(matches!(pt(json!({})), ParamType::Unknown(_)));
assert!(matches!(pt(json!("nonsense")), ParamType::Unknown(_)));
assert!(matches!(
pt(json!({"$ref": "https://example.com/Weird"})),
ParamType::Unknown(_)
));
assert!(matches!(
pt(json!({"type": "array"})),
ParamType::Unknown(_)
));
}
}