use std::collections::BTreeMap;
use serde::de::{self, Deserializer};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
pub use runx_contracts_derive::RunxSchema;
pub fn deserialize_true_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
let value = bool::deserialize(deserializer)?;
if value {
Ok(true)
} else {
Err(de::Error::custom("value must be true"))
}
}
pub fn deserialize_false_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
let value = bool::deserialize(deserializer)?;
if value {
Err(de::Error::custom("value must be false"))
} else {
Ok(false)
}
}
pub trait RunxSchema {
fn json_schema() -> Value;
}
pub struct Property {
pub name: &'static str,
pub schema: Value,
pub required: bool,
}
impl Property {
pub fn new(name: &'static str, schema: Value, required: bool) -> Self {
Self {
name,
schema,
required,
}
}
}
pub enum Identity<'a> {
Runx {
logical: &'a str,
url: Option<&'a str>,
},
BareId { url: &'a str },
}
pub fn object_schema(
properties: Vec<Property>,
deny_unknown: bool,
identity: Option<Identity<'_>>,
) -> Value {
let mut required: Vec<Value> = Vec::new();
let mut props = Map::new();
for property in properties {
if property.required {
required.push(Value::String(property.name.to_owned()));
}
props.insert(property.name.to_owned(), property.schema);
}
let mut schema = Map::new();
if let Some(identity) = identity {
schema.insert(
"$schema".to_owned(),
json!("https://json-schema.org/draft/2020-12/schema"),
);
match identity {
Identity::Runx { logical, url } => {
let id = url
.map(str::to_owned)
.unwrap_or_else(|| schema_id_url(logical));
schema.insert("$id".to_owned(), json!(id));
schema.insert("x-runx-schema".to_owned(), json!(logical));
props
.entry("schema".to_owned())
.or_insert_with(|| const_string(logical));
}
Identity::BareId { url } => {
schema.insert("$id".to_owned(), json!(url));
}
}
}
schema.insert("additionalProperties".to_owned(), json!(!deny_unknown));
schema.insert("type".to_owned(), json!("object"));
if !required.is_empty() {
schema.insert("required".to_owned(), Value::Array(required));
}
schema.insert("properties".to_owned(), Value::Object(props));
Value::Object(schema)
}
pub fn object_schema_with_flatten(
properties: Vec<Property>,
flattened: Vec<Value>,
deny_unknown: bool,
identity: Option<Identity<'_>>,
) -> Value {
let mut required: Vec<Value> = Vec::new();
let mut props = Map::new();
for property in properties {
if property.required {
required.push(Value::String(property.name.to_owned()));
}
props.insert(property.name.to_owned(), property.schema);
}
let mut deny_unknown = deny_unknown;
for flat in flattened {
let is_object = flat.get("type").and_then(Value::as_str) == Some("object");
match flat.get("properties").and_then(Value::as_object) {
Some(inner_props) if is_object => {
let inner_required: Vec<&str> = flat
.get("required")
.and_then(Value::as_array)
.map(|items| items.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
for (name, schema) in inner_props {
if inner_required.contains(&name.as_str()) {
required.push(Value::String(name.clone()));
}
props.insert(name.clone(), schema.clone());
}
}
_ => {
deny_unknown = false;
}
}
}
let mut schema = Map::new();
if let Some(identity) = identity {
schema.insert(
"$schema".to_owned(),
json!("https://json-schema.org/draft/2020-12/schema"),
);
match identity {
Identity::Runx { logical, url } => {
let id = url
.map(str::to_owned)
.unwrap_or_else(|| schema_id_url(logical));
schema.insert("$id".to_owned(), json!(id));
schema.insert("x-runx-schema".to_owned(), json!(logical));
props
.entry("schema".to_owned())
.or_insert_with(|| const_string(logical));
}
Identity::BareId { url } => {
schema.insert("$id".to_owned(), json!(url));
}
}
}
schema.insert("additionalProperties".to_owned(), json!(!deny_unknown));
schema.insert("type".to_owned(), json!("object"));
if !required.is_empty() {
schema.insert("required".to_owned(), Value::Array(required));
}
schema.insert("properties".to_owned(), Value::Object(props));
Value::Object(schema)
}
pub fn object_map_schema(value_schema: Value, identity: Option<Identity<'_>>) -> Value {
let mut schema = Map::new();
if let Some(identity) = identity {
schema.insert(
"$schema".to_owned(),
json!("https://json-schema.org/draft/2020-12/schema"),
);
match identity {
Identity::Runx { logical, url } => {
let id = url
.map(str::to_owned)
.unwrap_or_else(|| schema_id_url(logical));
schema.insert("$id".to_owned(), json!(id));
schema.insert("x-runx-schema".to_owned(), json!(logical));
}
Identity::BareId { url } => {
schema.insert("$id".to_owned(), json!(url));
}
}
}
schema.insert("type".to_owned(), json!("object"));
schema.insert(
"patternProperties".to_owned(),
json!({ "^(.*)$": value_schema }),
);
Value::Object(schema)
}
pub fn string_enum(variants: &[&str]) -> Value {
let any_of: Vec<Value> = variants
.iter()
.map(|variant| const_string(variant))
.collect();
json!({ "anyOf": any_of })
}
pub fn any_of(variants: Vec<Value>) -> Value {
json!({ "anyOf": variants })
}
pub fn any_of_with_identity(variants: Vec<Value>, identity: Option<Identity<'_>>) -> Value {
let mut schema = Map::new();
if let Some(identity) = identity {
schema.insert(
"$schema".to_owned(),
json!("https://json-schema.org/draft/2020-12/schema"),
);
match identity {
Identity::Runx { logical, url } => {
let id = url
.map(str::to_owned)
.unwrap_or_else(|| schema_id_url(logical));
schema.insert("$id".to_owned(), json!(id));
schema.insert("x-runx-schema".to_owned(), json!(logical));
}
Identity::BareId { url } => {
schema.insert("$id".to_owned(), json!(url));
}
}
}
schema.insert("anyOf".to_owned(), Value::Array(variants));
Value::Object(schema)
}
pub fn nullable(inner: Value) -> Value {
json!({ "anyOf": [inner, { "type": "null" }] })
}
pub fn externally_tagged_variant(tag: &'static str, inner: Value) -> Value {
object_schema(vec![Property::new(tag, inner, true)], true, None)
}
pub fn const_string(value: &str) -> Value {
json!({ "const": value, "type": "string" })
}
pub fn schema_id_url(logical: &str) -> String {
let path = logical
.split('.')
.map(|segment| segment.replace('_', "-"))
.collect::<Vec<_>>()
.join("/");
format!("https://schemas.runx.ai/{path}.json")
}
impl RunxSchema for String {
fn json_schema() -> Value {
json!({ "type": "string" })
}
}
impl RunxSchema for bool {
fn json_schema() -> Value {
json!({ "type": "boolean" })
}
}
impl RunxSchema for f64 {
fn json_schema() -> Value {
json!({ "type": "number" })
}
}
macro_rules! integer_schema {
($($ty:ty),+) => {
$(impl RunxSchema for $ty {
fn json_schema() -> Value {
json!({ "type": "integer" })
}
})+
};
}
integer_schema!(i8, i16, i32, i64, isize, u8, u16, u32, u64, usize);
impl<T: RunxSchema> RunxSchema for Vec<T> {
fn json_schema() -> Value {
json!({ "type": "array", "items": T::json_schema() })
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct NonEmptyVec<T>(Vec<T>);
impl<T> NonEmptyVec<T> {
pub fn new(value: Vec<T>) -> Option<Self> {
if value.is_empty() {
None
} else {
Some(Self(value))
}
}
pub fn as_slice(&self) -> &[T] {
&self.0
}
pub fn into_vec(self) -> Vec<T> {
self.0
}
}
impl<T> From<Vec<T>> for NonEmptyVec<T> {
fn from(value: Vec<T>) -> Self {
debug_assert!(
!value.is_empty(),
"NonEmptyVec::from received an empty outbound value"
);
Self(value)
}
}
impl<T> std::ops::Deref for NonEmptyVec<T> {
type Target = [T];
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'de, T> Deserialize<'de> for NonEmptyVec<T>
where
T: Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Vec::<T>::deserialize(deserializer)?;
Self::new(value)
.ok_or_else(|| serde::de::Error::custom("array must contain at least one item"))
}
}
impl<T: RunxSchema> RunxSchema for NonEmptyVec<T> {
fn json_schema() -> Value {
let mut schema = Vec::<T>::json_schema();
if let Some(object) = schema.as_object_mut() {
object.insert("minItems".to_owned(), json!(1));
}
schema
}
}
impl<T: RunxSchema> RunxSchema for Option<T> {
fn json_schema() -> Value {
T::json_schema()
}
}
impl<T: RunxSchema> RunxSchema for Box<T> {
fn json_schema() -> Value {
T::json_schema()
}
}
impl<T: RunxSchema> RunxSchema for BTreeMap<String, T> {
fn json_schema() -> Value {
json!({ "type": "object", "additionalProperties": T::json_schema() })
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(transparent)]
pub struct NonEmptyString(String);
impl NonEmptyString {
pub fn new(value: impl Into<String>) -> Option<Self> {
let value = value.into();
if value.is_empty() {
None
} else {
Some(Self(value))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl From<String> for NonEmptyString {
fn from(value: String) -> Self {
debug_assert!(
!value.is_empty(),
"NonEmptyString::from received an empty outbound value"
);
Self(value)
}
}
impl From<&str> for NonEmptyString {
fn from(value: &str) -> Self {
debug_assert!(
!value.is_empty(),
"NonEmptyString::from received an empty outbound value"
);
Self(value.to_owned())
}
}
impl PartialEq<String> for NonEmptyString {
fn eq(&self, other: &String) -> bool {
&self.0 == other
}
}
impl PartialEq<&str> for NonEmptyString {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<NonEmptyString> for String {
fn eq(&self, other: &NonEmptyString) -> bool {
self == &other.0
}
}
impl PartialEq<NonEmptyString> for str {
fn eq(&self, other: &NonEmptyString) -> bool {
self == other.0.as_str()
}
}
impl std::ops::Deref for NonEmptyString {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl AsRef<str> for NonEmptyString {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for NonEmptyString {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl PartialEq<str> for NonEmptyString {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl<'de> Deserialize<'de> for NonEmptyString {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).ok_or_else(|| serde::de::Error::custom("string must be non-empty"))
}
}
impl RunxSchema for NonEmptyString {
fn json_schema() -> Value {
json!({ "minLength": 1, "type": "string" })
}
}
pub const ISO_DATETIME_PATTERN: &str = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$";
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(transparent)]
pub struct IsoDateTime(String);
impl IsoDateTime {
pub fn new(value: impl Into<String>) -> Option<Self> {
let value = value.into();
if value.is_empty() {
None
} else {
Some(Self(value))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl From<String> for IsoDateTime {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&str> for IsoDateTime {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
impl std::ops::Deref for IsoDateTime {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl AsRef<str> for IsoDateTime {
fn as_ref(&self) -> &str {
&self.0
}
}
impl PartialEq<String> for IsoDateTime {
fn eq(&self, other: &String) -> bool {
&self.0 == other
}
}
impl PartialEq<&str> for IsoDateTime {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<str> for IsoDateTime {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<IsoDateTime> for String {
fn eq(&self, other: &IsoDateTime) -> bool {
self == &other.0
}
}
impl PartialEq<IsoDateTime> for str {
fn eq(&self, other: &IsoDateTime) -> bool {
self == other.0.as_str()
}
}
impl std::fmt::Display for IsoDateTime {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for IsoDateTime {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).ok_or_else(|| serde::de::Error::custom("datetime must be non-empty"))
}
}
impl RunxSchema for IsoDateTime {
fn json_schema() -> Value {
json!({ "minLength": 1, "pattern": ISO_DATETIME_PATTERN, "type": "string" })
}
}