///|
pub(all) enum ValueKind {
KindAny
KindNull
KindBool
KindInt
KindText
KindList
KindRecord
} derive(Eq, @debug.Debug)
///|
pub(all) struct FieldSpec {
path : String
kind : ValueKind
required : Bool
min_int : Int?
max_int : Int?
min_len : Int?
max_len : Int?
} derive(Eq, @debug.Debug)
///|
pub(all) struct SchemaIssue {
path : String
code : String
message : String
} derive(Eq, @debug.Debug)
///|
pub(all) struct SchemaReport {
name : String
valid : Bool
issues : Array[SchemaIssue]
} derive(Eq, @debug.Debug)
///|
pub(all) struct EventSchema {
name : String
topic : TopicPattern?
fields : Array[FieldSpec]
} derive(Eq, @debug.Debug)
///|
pub fn field_spec(
path : StringView,
kind : ValueKind,
required? : Bool = true,
min_int? : Int,
max_int? : Int,
min_len? : Int,
max_len? : Int,
) -> FieldSpec {
{ path: path.to_owned(), kind, required, min_int, max_int, min_len, max_len }
}
///|
pub fn event_schema(
name : StringView,
topic? : StringView = "",
fields? : ArrayView[FieldSpec] = [],
) -> Result[EventSchema, EventRailError] {
let parsed = if topic == "" {
None
} else {
match topic_pattern(topic) {
Ok(value) => Some(value)
Err(err) => return Err(err)
}
}
Ok({ name: name.to_owned(), topic: parsed, fields: fields.to_owned() })
}
///|
pub fn EventSchema::validate(
self : EventSchema,
event : Envelope,
) -> SchemaReport {
let issues : Array[SchemaIssue] = []
match self.topic {
Some(pattern) =>
match pattern.matches_topic(event.topic) {
Ok(true) => ()
Ok(false) =>
issues.push(
schema_issue(
"$topic",
"topic.mismatch",
"topic \{event.topic} does not match \{pattern.raw}",
),
)
Err(err) =>
issues.push(schema_issue("$topic", "topic.invalid", err.message()))
}
None => ()
}
for spec in self.fields {
validate_field_spec(spec, event.payload, issues)
}
{ name: self.name, valid: issues.length() == 0, issues }
}
///|
pub fn SchemaReport::summary(self : SchemaReport) -> String {
if self.valid {
"schema=\{self.name} valid"
} else {
"schema=\{self.name} invalid issues=\{self.issues.length()}"
}
}
///|
pub fn SchemaReport::to_handler_result(self : SchemaReport) -> HandlerResult {
if self.valid {
HandlerAck(self.summary())
} else {
HandlerDrop(self.issue_lines().join("; "))
}
}
///|
pub fn SchemaReport::issue_lines(self : SchemaReport) -> Array[String] {
self.issues.map(issue => "\{issue.path}:\{issue.code}:\{issue.message}")
}
///|
pub fn SchemaIssue::to_wire(self : SchemaIssue) -> String {
"path=\{escape_wire_text(self.path)};code=\{escape_wire_text(self.code)};message=\{escape_wire_text(self.message)}"
}
///|
fn validate_field_spec(
spec : FieldSpec,
payload : EventValue,
issues : Array[SchemaIssue],
) -> Unit {
match payload.get_path(spec.path) {
None =>
if spec.required {
issues.push(
schema_issue(
spec.path,
"field.required",
"required field \{spec.path} is missing",
),
)
}
Some(value) =>
if !kind_matches(spec.kind, value) {
issues.push(
schema_issue(
spec.path,
"field.kind",
"field \{spec.path} expected \{spec.kind.to_wire()}",
),
)
} else {
validate_field_bounds(spec, value, issues)
}
}
}
///|
fn validate_field_bounds(
spec : FieldSpec,
value : EventValue,
issues : Array[SchemaIssue],
) -> Unit {
match value {
VInt(actual) => {
match spec.min_int {
Some(minimum) if actual < minimum =>
issues.push(
schema_issue(
spec.path,
"int.min",
"field \{spec.path} expected >= \{minimum} but got \{actual}",
),
)
_ => ()
}
match spec.max_int {
Some(maximum) if actual > maximum =>
issues.push(
schema_issue(
spec.path,
"int.max",
"field \{spec.path} expected <= \{maximum} but got \{actual}",
),
)
_ => ()
}
}
VText(actual) => validate_length_bounds(spec, actual.length(), issues)
VList(values) => validate_length_bounds(spec, values.length(), issues)
VRecord(fields) => validate_length_bounds(spec, fields.length(), issues)
VNull | VBool(_) => ()
}
}
///|
fn validate_length_bounds(
spec : FieldSpec,
actual : Int,
issues : Array[SchemaIssue],
) -> Unit {
match spec.min_len {
Some(minimum) if actual < minimum =>
issues.push(
schema_issue(
spec.path,
"len.min",
"field \{spec.path} length expected >= \{minimum} but got \{actual}",
),
)
_ => ()
}
match spec.max_len {
Some(maximum) if actual > maximum =>
issues.push(
schema_issue(
spec.path,
"len.max",
"field \{spec.path} length expected <= \{maximum} but got \{actual}",
),
)
_ => ()
}
}
///|
fn kind_matches(kind : ValueKind, value : EventValue) -> Bool {
match kind {
KindAny => true
KindNull => value is VNull
KindBool => value is VBool(_)
KindInt => value is VInt(_)
KindText => value is VText(_)
KindList => value is VList(_)
KindRecord => value is VRecord(_)
}
}
///|
pub fn ValueKind::to_wire(self : ValueKind) -> String {
match self {
KindAny => "any"
KindNull => "null"
KindBool => "bool"
KindInt => "int"
KindText => "text"
KindList => "list"
KindRecord => "record"
}
}
///|
fn schema_issue(
path : StringView,
code : StringView,
message : StringView,
) -> SchemaIssue {
{ path: path.to_owned(), code: code.to_owned(), message: message.to_owned() }
}