use crate::{FieldMetadata, ModelDescriptor, SchemaKind, TypeDescriptor, ValidationRule};
use blazingly_json::{Map, Value, json};
pub trait SchemaDialect {
fn model_node(&self, descriptor: &TypeDescriptor, model: &ModelDescriptor) -> Value;
fn binary_node(&self) -> Value;
fn project_custom_validator(&self, _schema: &mut Value, _validator: &str) -> bool {
false
}
}
#[must_use]
pub fn schema_value(dialect: &impl SchemaDialect, descriptor: &TypeDescriptor) -> Value {
let mut value = if let Some(model) = &descriptor.model {
dialect.model_node(descriptor, model)
} else {
let mut value = match (&descriptor.schema, &descriptor.items) {
(SchemaKind::Array(_), Some(items)) => {
json!({ "type": "array", "items": schema_value(dialect, items) })
}
_ => schema_kind_value(dialect, &descriptor.schema),
};
apply_known_string_format(&mut value, &descriptor.rust_name);
value["x-rust-type"] = Value::String(descriptor.rust_name.clone());
value
};
apply_validation(dialect, &mut value, &descriptor.constraints);
value
}
#[must_use]
pub fn schema_kind_value(dialect: &impl SchemaDialect, schema: &SchemaKind) -> Value {
match schema {
SchemaKind::String => json!({ "type": "string" }),
SchemaKind::Binary => dialect.binary_node(),
SchemaKind::Integer => json!({ "type": "integer" }),
SchemaKind::Number => json!({ "type": "number" }),
SchemaKind::Boolean => json!({ "type": "boolean" }),
SchemaKind::Array(item) => {
json!({ "type": "array", "items": schema_kind_value(dialect, item) })
}
SchemaKind::Object => json!({ "type": "object" }),
SchemaKind::Any => json!({}),
}
}
#[must_use]
pub fn model_schema(dialect: &impl SchemaDialect, model: &ModelDescriptor) -> Value {
let mut properties = Map::new();
let mut required = Vec::new();
for field in &model.fields {
let mut schema = schema_value(dialect, &field.ty);
apply_validation(dialect, &mut schema, &field.validation);
properties.insert(field.name.clone(), schema);
if field.required {
required.push(field.name.clone());
}
}
json!({
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": false
})
}
pub fn apply_field_metadata(schema: &mut Value, metadata: &FieldMetadata) {
match metadata {
FieldMetadata::Default(value) => schema["default"] = value.clone(),
FieldMetadata::Enumeration(values) => {
schema["enum"] = Value::Array(
values
.iter()
.map(|value| Value::String(value.clone()))
.collect(),
);
}
FieldMetadata::Nullable => widen_with_null(schema),
}
}
pub fn apply_validation(
dialect: &impl SchemaDialect,
schema: &mut Value,
validation: &[ValidationRule],
) {
for rule in validation {
match rule {
ValidationRule::MinLength(value) => schema["minLength"] = json!(value),
ValidationRule::MaxLength(value) => schema["maxLength"] = json!(value),
ValidationRule::Email => schema["format"] = json!("email"),
ValidationRule::Alias(alias) => push_extension(schema, "x-blazingly-aliases", alias),
ValidationRule::Custom(validator) => {
if let Some(metadata) = FieldMetadata::parse(validator) {
apply_field_metadata(schema, &metadata);
continue;
}
if dialect.project_custom_validator(schema, validator) {
continue;
}
push_extension(schema, "x-blazingly-validators", validator);
}
ValidationRule::Nested => {
schema["x-blazingly-nested-validation"] = Value::Bool(true);
}
}
}
}
pub fn push_extension(schema: &mut Value, keyword: &str, name: &str) {
let names = schema
.as_object_mut()
.expect("validation schema must be an object")
.entry(keyword)
.or_insert_with(|| Value::Array(Vec::new()))
.as_array_mut()
.expect("a document extension list must be an array");
if !names.iter().any(|declared| declared.as_str() == Some(name)) {
names.push(Value::String(name.to_owned()));
}
}
fn apply_known_string_format(schema: &mut Value, rust_name: &str) {
let format = match rust_name {
"Uuid" => "uuid",
"Url" => "uri",
"IpAddress" => "ip",
"Date" => "date",
"DateTime" => "date-time",
"Decimal" => "decimal",
_ => return,
};
schema["format"] = Value::String(format.to_owned());
}
fn widen_with_null(schema: &mut Value) {
let Some(declared) = schema.as_object().map(|object| object.get("type").cloned()) else {
return;
};
match declared {
Some(Value::String(name)) => schema["type"] = json!([name, "null"]),
Some(Value::Array(mut names)) => {
if !names.iter().any(|name| name.as_str() == Some("null")) {
names.push(Value::String("null".to_owned()));
schema["type"] = Value::Array(names);
}
}
Some(_) | None => {
if schema.get("$ref").is_some() {
let referenced = std::mem::replace(schema, Value::Null);
*schema = json!({ "anyOf": [referenced, { "type": "null" }] });
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::FieldDescriptor;
struct Inline;
impl SchemaDialect for Inline {
fn model_node(&self, _descriptor: &TypeDescriptor, model: &ModelDescriptor) -> Value {
model_schema(self, model)
}
fn binary_node(&self) -> Value {
json!({ "type": "string", "contentEncoding": "base64" })
}
}
struct Referencing;
impl SchemaDialect for Referencing {
fn model_node(&self, _descriptor: &TypeDescriptor, model: &ModelDescriptor) -> Value {
json!({ "$ref": format!("#/components/schemas/{}", model.name) })
}
fn binary_node(&self) -> Value {
json!({ "type": "string", "format": "binary" })
}
fn project_custom_validator(&self, schema: &mut Value, validator: &str) -> bool {
if validator == "handled" {
schema["x-handled"] = Value::Bool(true);
return true;
}
false
}
}
fn article() -> ModelDescriptor {
ModelDescriptor::new(
"Article",
vec![FieldDescriptor::new(
"title",
true,
TypeDescriptor::scalar("String", SchemaKind::String),
Vec::new(),
)],
)
}
#[test]
fn the_dialect_decides_how_a_model_appears() {
let descriptor = TypeDescriptor::model(article());
let inline = schema_value(&Inline, &descriptor);
let referenced = schema_value(&Referencing, &descriptor);
assert_eq!(inline["type"], "object");
assert_eq!(inline["properties"]["title"]["type"], "string");
assert_eq!(referenced["$ref"], "#/components/schemas/Article");
}
#[test]
fn the_dialect_decides_how_binary_payloads_are_spelled() {
let descriptor = TypeDescriptor::scalar("Vec<u8>", SchemaKind::Binary);
assert_eq!(
schema_value(&Inline, &descriptor)["contentEncoding"],
"base64"
);
assert_eq!(schema_value(&Referencing, &descriptor)["format"], "binary");
}
#[test]
fn type_constraints_recurse_into_items_at_any_depth() {
let tag = TypeDescriptor::scalar("Tag", SchemaKind::String).with_constraints(vec![
ValidationRule::MinLength(1),
ValidationRule::MaxLength(20),
]);
let tags = TypeDescriptor {
rust_name: "Vec<Vec<Tag>>".to_owned(),
schema: SchemaKind::Array(Box::new(SchemaKind::Array(Box::new(SchemaKind::String)))),
model: None,
items: Some(Box::new(TypeDescriptor {
rust_name: "Vec<Tag>".to_owned(),
schema: SchemaKind::Array(Box::new(SchemaKind::String)),
model: None,
items: Some(Box::new(tag)),
constraints: Vec::new(),
})),
constraints: Vec::new(),
};
for dialect in [
schema_value(&Inline, &tags),
schema_value(&Referencing, &tags),
] {
assert_eq!(dialect["items"]["items"]["minLength"], 1, "{dialect}");
assert_eq!(dialect["items"]["items"]["maxLength"], 20, "{dialect}");
assert!(dialect["minLength"].is_null(), "{dialect}");
}
}
#[test]
fn nullability_widens_a_type_and_wraps_a_reference() {
let mut typed = json!({ "type": "string" });
apply_field_metadata(&mut typed, &FieldMetadata::Nullable);
assert_eq!(typed["type"], json!(["string", "null"]));
apply_field_metadata(&mut typed, &FieldMetadata::Nullable);
assert_eq!(
typed["type"],
json!(["string", "null"]),
"widening is idempotent"
);
let mut referenced = json!({ "$ref": "#/components/schemas/Article" });
apply_field_metadata(&mut referenced, &FieldMetadata::Nullable);
assert_eq!(
referenced["anyOf"][0]["$ref"],
"#/components/schemas/Article"
);
assert_eq!(referenced["anyOf"][1]["type"], "null");
}
#[test]
fn a_custom_validator_is_offered_to_the_dialect_before_the_extension_array() {
let handled_rule = vec![ValidationRule::Custom("handled".to_owned())];
let mut handled = json!({ "type": "string" });
apply_validation(&Referencing, &mut handled, &handled_rule);
assert_eq!(handled["x-handled"], json!(true));
assert!(handled["x-blazingly-validators"].is_null());
let opaque_rule = vec![ValidationRule::Custom("opaque".to_owned())];
let mut declined = json!({ "type": "string" });
apply_validation(&Referencing, &mut declined, &opaque_rule);
assert_eq!(declined["x-blazingly-validators"], json!(["opaque"]));
apply_validation(&Referencing, &mut declined, &opaque_rule);
assert_eq!(
declined["x-blazingly-validators"],
json!(["opaque"]),
"a doubly projected validator is recorded once"
);
}
}