use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum TypeRepr {
Unit,
Bool,
Int,
Float,
String,
List {
element: Box<TypeRepr>,
},
Option {
inner: Box<TypeRepr>,
},
Result {
ok: Box<TypeRepr>,
err: Box<TypeRepr>,
},
Enum {
name: String,
variants: Vec<EnumVariant>,
},
Schema {
schema: Box<Schema>,
},
Closure {
params: Vec<TypeRepr>,
ret: Box<TypeRepr>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Field {
pub name: String,
pub ty: TypeRepr,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnumVariant {
pub name: String,
pub tag: u8,
pub fields: Vec<Field>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_tuple: bool,
}
impl EnumVariant {
pub fn payload_schema(&self, enum_name: &str) -> Option<Schema> {
if self.fields.is_empty() {
return None;
}
Some(Schema {
name: format!("{enum_name}.{}", self.name),
generics: Vec::new(),
fields: self.fields.clone(),
is_tuple: false,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Schema {
pub name: String,
pub generics: Vec<String>,
pub fields: Vec<Field>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_tuple: bool,
}
impl TypeRepr {
pub fn enum_variant_by_tag(&self, tag: u8) -> Option<&EnumVariant> {
match self {
TypeRepr::Enum { variants, .. } => variants.iter().find(|v| v.tag == tag),
_ => None,
}
}
pub fn enum_variant_by_name(&self, variant_name: &str) -> Option<&EnumVariant> {
match self {
TypeRepr::Enum { variants, .. } => variants.iter().find(|v| v.name == variant_name),
_ => None,
}
}
}
impl Schema {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
generics: Vec::new(),
fields: Vec::new(),
is_tuple: false,
}
}
pub fn tuple(name: impl Into<String>, elements: Vec<TypeRepr>) -> Self {
let fields = elements
.into_iter()
.enumerate()
.map(|(i, ty)| Field {
name: i.to_string(),
ty,
default: None,
})
.collect();
Self {
name: name.into(),
generics: Vec::new(),
fields,
is_tuple: true,
}
}
}
pub fn canonical_schema(schema: &Schema) -> Vec<u8> {
let value = serde_json::json!({
"version": 3,
"schema": schema,
});
serde_json::to_vec(&value).expect("canonical schema serialisation never fails on owned types")
}
pub fn schema_hash(schema: &Schema) -> [u8; 32] {
let canonical = canonical_schema(schema);
let mut hasher = Sha256::new();
hasher.update(&canonical);
hasher.finalize().into()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_user_schema() -> Schema {
Schema {
name: "User".into(),
generics: vec![],
is_tuple: false,
fields: vec![
Field {
name: "id".into(),
ty: TypeRepr::Int,
default: None,
},
Field {
name: "name".into(),
ty: TypeRepr::String,
default: None,
},
Field {
name: "active".into(),
ty: TypeRepr::Bool,
default: Some(serde_json::Value::Bool(true)),
},
],
}
}
#[test]
fn identical_schemas_produce_identical_hash() {
let a = sample_user_schema();
let b = sample_user_schema();
assert_eq!(schema_hash(&a), schema_hash(&b));
}
#[test]
fn field_reorder_changes_hash() {
let mut original = sample_user_schema();
let mut reordered = sample_user_schema();
reordered.fields.swap(0, 1);
assert_ne!(schema_hash(&original), schema_hash(&reordered));
original.fields.sort_by(|a, b| a.name.cmp(&b.name));
reordered.fields.sort_by(|a, b| a.name.cmp(&b.name));
assert_eq!(original.fields, reordered.fields);
}
#[test]
fn doc_comment_and_metadata_absent_means_hash_stable() {
let schema = sample_user_schema();
let bytes = canonical_schema(&schema);
let text = std::str::from_utf8(&bytes).expect("canonical form is utf-8 json");
assert!(
!text.contains("\"doc"),
"canonical form must not contain doc-related keys, got: {text}"
);
assert!(
!text.contains("\"meta"),
"canonical form must not contain decorator metadata keys, got: {text}"
);
}
#[test]
fn nested_schema_inline_matches_flattened_equivalent() {
let inner = sample_user_schema();
let outer_with_named = Schema {
name: "Wrapper".into(),
generics: vec![],
is_tuple: false,
fields: vec![Field {
name: "user".into(),
ty: TypeRepr::Schema {
schema: Box::new(inner.clone()),
},
default: None,
}],
};
let outer_with_alias = Schema {
name: "Wrapper".into(),
generics: vec![],
is_tuple: false,
fields: vec![Field {
name: "user".into(),
ty: TypeRepr::Schema {
schema: Box::new(Schema {
name: inner.name.clone(),
generics: inner.generics.clone(),
fields: inner.fields.clone(),
is_tuple: false,
}),
},
default: None,
}],
};
assert_eq!(
schema_hash(&outer_with_named),
schema_hash(&outer_with_alias)
);
}
#[test]
fn different_field_default_changes_hash() {
let mut a = sample_user_schema();
let mut b = sample_user_schema();
a.fields[2].default = Some(serde_json::Value::Bool(true));
b.fields[2].default = Some(serde_json::Value::Bool(false));
assert_ne!(schema_hash(&a), schema_hash(&b));
}
#[test]
fn canonical_form_is_compact_json() {
let schema = sample_user_schema();
let bytes = canonical_schema(&schema);
assert!(
!bytes.contains(&b' '),
"canonical form must contain no spaces"
);
assert!(
!bytes.contains(&b'\n'),
"canonical form must contain no newlines"
);
}
}