use std::fmt::{Display, Formatter, Result as FmtResult};
use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use type_history_core::resolved::{SchemaField, SchemaShape};
use crate::canonical::{self, CanonicalJsonError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JsonSchemaDocument {
identity: String,
value: Value,
}
impl JsonSchemaDocument {
pub const META_SCHEMA: &'static str = "https://json-schema.org/draft/2020-12/schema";
pub fn from_export(mut raw: Value, identity: &str) -> Result<Self, JsonSchemaError> {
let Some(root) = raw.as_object_mut() else {
return Err(JsonSchemaError::at("", JsonSchemaErrorReason::NotAnObject));
};
root.insert(
"$schema".to_owned(),
Value::String(Self::META_SCHEMA.to_owned()),
);
root.insert("$id".to_owned(), Value::String(identity.to_owned()));
normalize(&mut raw)?;
Self::from_normalized(raw)
}
pub fn from_shape(shape: &SchemaShape, identity: &str) -> Self {
Self::try_from_shape(shape, identity).expect("resolved shape is valid")
}
pub fn try_from_shape(shape: &SchemaShape, identity: &str) -> Result<Self, JsonSchemaError> {
validate::shape(shape, "")?;
let mut value = write_node(shape);
let root = value.as_object_mut().expect("written nodes are objects");
root.insert(
"$schema".to_owned(),
Value::String(Self::META_SCHEMA.to_owned()),
);
root.insert("$id".to_owned(), Value::String(identity.to_owned()));
Self::from_normalized(value)
}
pub fn from_normalized(value: Value) -> Result<Self, JsonSchemaError> {
let mut normalized = value.clone();
normalize(&mut normalized)?;
if normalized != value {
return Err(JsonSchemaError::at(
"",
JsonSchemaErrorReason::NotNormalized,
));
}
let identity = read_identity(&value)?;
parse(&value)?;
Ok(Self { identity, value })
}
pub fn identity(&self) -> &str {
&self.identity
}
pub fn as_value(&self) -> &Value {
&self.value
}
pub fn shape(&self) -> SchemaShape {
parse(&self.value).expect("constructed documents are structurally valid")
}
pub fn canonical_bytes(&self) -> Result<Vec<u8>, CanonicalJsonError> {
canonical::value_to_vec(&self.value)
}
}
impl Serialize for JsonSchemaDocument {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.value.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for JsonSchemaDocument {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = Value::deserialize(deserializer)?;
Self::from_normalized(value).map_err(D::Error::custom)
}
}
pub fn record(mut fields: Vec<SchemaField>) -> SchemaShape {
fields.sort_by(|left, right| left.name.cmp(&right.name));
SchemaShape::Record { fields }
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub struct JsonSchemaError {
pub path: String,
pub reason: JsonSchemaErrorReason,
}
impl JsonSchemaError {
fn at(path: &str, reason: JsonSchemaErrorReason) -> Self {
Self {
path: path.to_owned(),
reason,
}
}
}
impl Display for JsonSchemaError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
if self.path.is_empty() {
write!(formatter, "JSON Schema document root: {}", self.reason)
} else {
write!(
formatter,
"JSON Schema document node `{}`: {}",
self.path, self.reason
)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum JsonSchemaErrorReason {
#[error("schema nodes must be JSON objects")]
NotAnObject,
#[error("document is not normalized")]
NotNormalized,
#[error("`$schema` must be `{}`", JsonSchemaDocument::META_SCHEMA)]
MetaSchema,
#[error("`$id` must be a nonempty complete schema identity")]
Identity,
#[error("references and definitions are not admitted; export with inline subschemas")]
Reference,
#[error("keyword `{0}` is not part of the Type History JSON Schema subset")]
UnknownKeyword(String),
#[error("keyword `{0}` has an invalid value")]
InvalidKeyword(String),
#[error("numbers must be integers")]
Number,
#[error("`type` must name one of boolean, string, number, integer, array, or object")]
Type,
#[error("integers require a `format` naming one of the eleven Rust integer widths")]
IntegerFormat,
#[error("integer bounds do not match the width named by `format`")]
IntegerBounds,
#[error("fixed arrays require equal `minItems` and `maxItems`")]
ArrayLength,
#[error("objects require `additionalProperties: false`")]
AdditionalProperties,
#[error("`required` must contain unique string names drawn from `properties`")]
Required,
#[error("record fields must have unique names")]
DuplicateField,
#[error("`anyOf` must contain exactly one value schema and one `null` schema")]
Option,
#[error("enumerations require unique string names, and a single name uses `const`")]
Enum,
#[error("each `oneOf` entry must be a unit `const` or one externally tagged variant")]
Variant,
#[error("tuples require at least one `prefixItems` entry and exact length constraints")]
Tuple,
#[error("`x-type-history-membership` must be a strict tree of nonempty IDs")]
Membership,
#[error("`x-type-history-profile` must be recognized and match its declared JSON type")]
Profile,
#[error("`uniqueItems` is not a certified Type History set membership declaration")]
UncertifiedSet,
#[error("canonical JSON serialization failed")]
Canonical,
}
impl From<CanonicalJsonError> for JsonSchemaError {
fn from(error: CanonicalJsonError) -> Self {
match error {
CanonicalJsonError::UnsupportedNumber => Self::at("", JsonSchemaErrorReason::Number),
CanonicalJsonError::Serialize(_) => Self::at("", JsonSchemaErrorReason::Canonical),
}
}
}
const NULL_TYPE: &str = "null";
fn read_identity(value: &Value) -> Result<String, JsonSchemaError> {
let root = value
.as_object()
.ok_or_else(|| JsonSchemaError::at("", JsonSchemaErrorReason::NotAnObject))?;
if root.get("$schema").and_then(Value::as_str) != Some(JsonSchemaDocument::META_SCHEMA) {
return Err(JsonSchemaError::at("", JsonSchemaErrorReason::MetaSchema));
}
root.get("$id")
.and_then(Value::as_str)
.filter(|identity| !identity.is_empty())
.map(str::to_owned)
.ok_or_else(|| JsonSchemaError::at("", JsonSchemaErrorReason::Identity))
}
mod normalize;
use normalize::normalize;
mod collections;
mod profiles;
mod write;
use write::write_node;
mod validate;
mod read;
pub use read::parse;
#[cfg(test)]
mod tests;