///|
/// Create a schema that validates JSON strings.
pub fn string(
required_error~ : String = "",
invalid_type_error~ : String = "",
) -> Schema {
{
id: new_schema_id(),
schema_type: StringType,
rules: [],
description: "",
required_error,
invalid_type_error,
name: "",
brand: "",
}
}
///|
fn schema_min_check(s : Schema, n : Double) -> (Json) -> Bool {
match inner_type(s.schema_type) {
StringType =>
fn(json) {
match json {
String(s) => ecma_code_point_length(s).to_double() >= n
_ => false
}
}
NumberType =>
fn(json) {
match json {
Number(v, ..) => v >= n
_ => false
}
}
ArrayType(_) =>
fn(json) {
match json {
Array(arr) => arr.length().to_double() >= n
_ => false
}
}
_ => fn(_) { false }
}
}
///|
fn schema_min_msg(s : Schema, n : Double) -> String {
match inner_type(s.schema_type) {
StringType => "String must contain at least \{n} character(s)"
NumberType => "Value must be >= \{n}"
ArrayType(_) => "Array must contain at least \{n} item(s)"
_ => abort("min() is only valid for string, number, or array schemas")
}
}
///|
/// Require the string length to be at least `n`.
pub fn[N : NumericBound] Schema::min(
self : Schema,
n : N,
msg~ : String = "",
) -> Schema {
let n = n.to_bound()
guard_sized_bound(self, n)
guard_no_user_transform(self)
let check = schema_min_check(self, n)
let message = if msg.is_empty() { schema_min_msg(self, n) } else { msg }
let annotation = match inner_type(self.schema_type) {
StringType => Json::object({ "minLength": Json::number(n) })
NumberType => Json::object({ "minimum": Json::number(n) })
ArrayType(_) => Json::object({ "minItems": Json::number(n) })
_ => Json::null()
}
let origin = match inner_type(self.schema_type) {
StringType => "string"
NumberType => "number"
ArrayType(_) => "array"
_ => "value"
}
append_builtin_rule(
self,
check,
message,
IssueCode::TooSmall(origin, n, true),
annotation,
explicit_message=!msg.is_empty(),
)
}
///|
fn schema_max_check(s : Schema, n : Double) -> (Json) -> Bool {
match inner_type(s.schema_type) {
StringType =>
fn(json) {
match json {
String(s) => ecma_code_point_length(s).to_double() <= n
_ => false
}
}
NumberType =>
fn(json) {
match json {
Number(v, ..) => v <= n
_ => false
}
}
ArrayType(_) =>
fn(json) {
match json {
Array(arr) => arr.length().to_double() <= n
_ => false
}
}
_ => fn(_) { false }
}
}
///|
fn schema_max_msg(s : Schema, n : Double) -> String {
match inner_type(s.schema_type) {
StringType => "String must contain at most \{n} character(s)"
NumberType => "Value must be <= \{n}"
ArrayType(_) => "Array must contain at most \{n} item(s)"
_ => abort("max() is only valid for string, number, or array schemas")
}
}
///|
/// Require the string length to be at most `n`.
pub fn[N : NumericBound] Schema::max(
self : Schema,
n : N,
msg~ : String = "",
) -> Schema {
let n = n.to_bound()
guard_sized_bound(self, n)
guard_no_user_transform(self)
let check = schema_max_check(self, n)
let message = if msg.is_empty() { schema_max_msg(self, n) } else { msg }
let annotation = match inner_type(self.schema_type) {
StringType => Json::object({ "maxLength": Json::number(n) })
NumberType => Json::object({ "maximum": Json::number(n) })
ArrayType(_) => Json::object({ "maxItems": Json::number(n) })
_ => Json::null()
}
let origin = match inner_type(self.schema_type) {
StringType => "string"
NumberType => "number"
ArrayType(_) => "array"
_ => "value"
}
append_builtin_rule(
self,
check,
message,
IssueCode::TooBig(origin, n, true),
annotation,
explicit_message=!msg.is_empty(),
)
}
///|
fn chars_to_string(chars : Array[Char], start : Int32, end : Int32) -> String {
let mut result = ""
for i = start; i < end; i = i + 1 {
result = result + chars[i].to_string()
}
result
}
///|
fn is_valid_email(s : String) -> Bool {
let parts = s.split("@").to_array()
if parts.length() != 2 || parts[0].is_empty() {
return false
}
let local_part = parts[0].to_array()
if local_part[0] == '.' || local_part[local_part.length() - 1] == '.' {
return false
}
let mut previous_dot = false
for c in local_part {
if c == '.' && previous_dot {
return false
}
if !((c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '_' ||
c == '+' ||
c == '-' ||
c == '\'' ||
c == '.') {
return false
}
previous_dot = c == '.'
}
let labels = parts[1].split(".").to_array()
if labels.length() < 2 {
return false
}
for label in labels {
if label.is_empty() || label[0] == '-' {
return false
}
for c in label.to_array() {
if !((c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-') {
return false
}
}
}
let tld = labels[labels.length() - 1]
tld.length() >= 2 &&
tld.to_array().all(fn(c) { (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') })
}
///|
/// Require the string to be a valid email.
pub fn Schema::email(self : Schema, msg~ : String = "") -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("email() is only valid for string schemas")
}
let message = if msg.is_empty() {
"String must be a valid email address"
} else {
msg
}
append_builtin_rule(
self,
fn(json) {
match json {
String(s) => is_valid_email(s)
_ => false
}
},
message,
IssueCode::InvalidFormat("email"),
Json::object({ "format": Json::string("email") }),
explicit_message=!msg.is_empty(),
)
}
///|
fn is_valid_url(s : String) -> Bool {
let chars = s.to_array()
let mut colon = -1
for i = 0; i < chars.length(); i = i + 1 {
if chars[i] == ':' {
colon = i
break
}
}
if colon <= 0 {
return false
}
if !((chars[0] >= 'a' && chars[0] <= 'z') ||
(chars[0] >= 'A' && chars[0] <= 'Z')) {
return false
}
for i = 1; i < colon; i = i + 1 {
let c = chars[i]
if !((c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '+' ||
c == '-' ||
c == '.') {
return false
}
}
let scheme = chars_to_string(chars, 0, colon).to_lower()
let special = scheme == "http" || scheme == "https" || scheme == "ftp"
let has_authority = colon + 2 < chars.length() &&
chars[colon + 1] == '/' &&
chars[colon + 2] == '/'
if !has_authority {
return !special && valid_uri_tail(chars, colon + 1)
}
let start = colon + 3
let mut end = start
while end < chars.length() &&
chars[end] != '/' &&
chars[end] != '?' &&
chars[end] != '#' {
end = end + 1
}
if !valid_uri_tail(chars, end) {
return false
}
if end == start {
return scheme == "file"
}
let mut host_start = start
let mut userinfo = false
for i = start; i < end; i = i + 1 {
if chars[i] == '@' {
if userinfo || !valid_uri_component(chars, start, i, ":") {
return false
}
userinfo = true
host_start = i + 1
}
}
if host_start >= end {
return false
}
let mut port_start = end
if chars[host_start] == '[' {
let mut closing = host_start + 1
while closing < end && chars[closing] != ']' {
closing = closing + 1
}
if closing == end ||
!is_valid_ipv6(chars_to_string(chars, host_start + 1, closing)) {
return false
}
if closing + 1 < end {
if chars[closing + 1] != ':' {
return false
}
port_start = closing + 2
}
} else {
let mut host_end = end
for i = host_start; i < end; i = i + 1 {
if chars[i] == ':' {
host_end = i
port_start = i + 1
break
}
}
if host_end == host_start {
return false
}
let host = chars_to_string(chars, host_start, host_end)
let mut numeric_host = true
for c in host.to_array() {
if !((c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '.' ||
c == '-' ||
c == '_') {
return false
}
if !(c >= '0' && c <= '9') && c != '.' {
numeric_host = false
}
}
if numeric_host && host.contains(".") && !is_valid_ipv4(host) {
return false
}
}
if port_start < end {
let mut port = 0
for i = port_start; i < end; i = i + 1 {
if chars[i] < '0' || chars[i] > '9' {
return false
}
port = port * 10 + chars[i].to_int() - 48
if port > 65535 {
return false
}
}
}
true
}
///|
/// RFC 3986 unreserved/sub-delims, plus each component's permitted delimiters.
fn valid_uri_component(
chars : Array[Char],
start : Int32,
end : Int32,
extra : String,
) -> Bool {
let mut i = start
while i < end {
let c = chars[i]
if c == '%' {
if i + 2 >= end ||
!is_hex_digit(chars[i + 1]) ||
!is_hex_digit(chars[i + 2]) {
return false
}
i = i + 3
continue
}
let unreserved = (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-' ||
c == '.' ||
c == '_' ||
c == '~'
let subdelim = c == '!' ||
c == '$' ||
c == '&' ||
c.to_int() == 39 ||
c == '(' ||
c == ')' ||
c == '*' ||
c == '+' ||
c == ',' ||
c == ';' ||
c == '='
if !unreserved && !subdelim && !extra.to_array().contains(c) {
return false
}
i = i + 1
}
true
}
///|
fn valid_uri_tail(chars : Array[Char], start : Int32) -> Bool {
let mut end = start
while end < chars.length() && chars[end] != '?' && chars[end] != '#' {
end = end + 1
}
if !valid_uri_component(chars, start, end, ":@/") {
return false
}
if end < chars.length() && chars[end] == '?' {
let query = end + 1
end = query
while end < chars.length() && chars[end] != '#' {
end = end + 1
}
if !valid_uri_component(chars, query, end, ":@/?") {
return false
}
}
if end < chars.length() {
return valid_uri_component(chars, end + 1, chars.length(), ":@/?")
}
true
}
///|
/// Require an absolute URI; authority validation supports IPv4/IPv6 and ports.
/// This portable validator does not perform WHATWG URL normalization.
pub fn Schema::url(self : Schema, msg~ : String = "") -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("url() is only valid for string schemas")
}
let message = if msg.is_empty() { "String must be a valid URL" } else { msg }
append_builtin_rule(
self,
fn(json) {
match json {
String(s) => is_valid_url(s)
_ => false
}
},
message,
IssueCode::InvalidFormat("url"),
Json::object({ "format": Json::string("uri") }),
explicit_message=!msg.is_empty(),
)
}
///|
/// Require the string to match the given regex pattern.
pub fn Schema::regex(
self : Schema,
pattern : String,
msg~ : String = "",
) -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("regex() is only valid for string schemas")
}
let message = if msg.is_empty() {
"String must match pattern: \{pattern}"
} else {
msg
}
let re = @regexp.compile(pattern) catch {
_ => abort("Invalid regex pattern: \{pattern}")
}
append_builtin_rule(
self,
fn(json) {
match json {
String(s) => re.execute(s).matched()
_ => false
}
},
message,
IssueCode::InvalidFormat("regex"),
Json::object({ "pattern": Json::string(pattern) }),
explicit_message=!msg.is_empty(),
)
}
///|
/// Require the string to start with the given prefix.
pub fn Schema::startsWith(
self : Schema,
prefix : String,
msg~ : String = "",
) -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("startsWith() is only valid for string schemas")
}
let message = if msg.is_empty() {
"String must start with \"\{prefix}\""
} else {
msg
}
append_builtin_rule(
self,
fn(json) {
match json {
String(s) => s.has_prefix(prefix)
_ => false
}
},
message,
IssueCode::InvalidFormat("starts_with"),
Json::object({ "pattern": Json::string("^" + escape_regex_literal(prefix)) }),
explicit_message=!msg.is_empty(),
constraint=Some(Prefix(prefix)),
)
}
///|
/// Require the string to end with the given suffix.
pub fn Schema::endsWith(
self : Schema,
suffix : String,
msg~ : String = "",
) -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("endsWith() is only valid for string schemas")
}
let message = if msg.is_empty() {
"String must end with \"\{suffix}\""
} else {
msg
}
append_builtin_rule(
self,
fn(json) {
match json {
String(s) => s.has_suffix(suffix)
_ => false
}
},
message,
IssueCode::InvalidFormat("ends_with"),
Json::object({ "pattern": Json::string(escape_regex_literal(suffix) + "$") }),
explicit_message=!msg.is_empty(),
constraint=Some(Suffix(suffix)),
)
}
///|
/// Require the string to contain the given substring.
pub fn Schema::includes(
self : Schema,
substring : String,
msg~ : String = "",
) -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("includes() is only valid for string schemas")
}
let message = if msg.is_empty() {
"String must include \"\{substring}\""
} else {
msg
}
append_builtin_rule(
self,
fn(json) {
match json {
String(s) => s.contains(substring)
_ => false
}
},
message,
IssueCode::InvalidFormat("includes"),
Json::object({ "pattern": Json::string(escape_regex_literal(substring)) }),
explicit_message=!msg.is_empty(),
constraint=Some(Contains(substring)),
)
}
///|
fn is_hex_digit(c : Char) -> Bool {
(c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
}
///|
/// Require the string to be a valid RFC UUID.
/// Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
pub fn Schema::uuid(self : Schema, msg~ : String = "") -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("uuid() is only valid for string schemas")
}
let message = if msg.is_empty() {
"String must be a valid RFC UUID"
} else {
msg
}
append_builtin_rule(
self,
fn(json) {
match json {
String(s) => {
if s == "00000000-0000-0000-0000-000000000000" ||
s == "ffffffff-ffff-ffff-ffff-ffffffffffff" {
return true
}
let chars = s.to_array()
if chars.length() != 36 {
return false
}
// Dashes at positions 8, 13, 18, 23
if chars[8] != '-' ||
chars[13] != '-' ||
chars[18] != '-' ||
chars[23] != '-' {
return false
}
// Version digit at position 14 must be '4'
if chars[14] < '1' || chars[14] > '8' {
return false
}
// Variant at position 19 must be 8/9/a/b/A/B
if chars[19] != '8' &&
chars[19] != '9' &&
chars[19] != 'a' &&
chars[19] != 'b' &&
chars[19] != 'A' &&
chars[19] != 'B' {
return false
}
let positions = [
0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 15, 16, 17, 20, 21, 22, 24, 25,
26, 27, 28, 29, 30, 31, 32, 33, 34, 35,
]
for i in positions {
if !is_hex_digit(chars[i]) {
return false
}
}
true
}
_ => false
}
},
message,
IssueCode::InvalidFormat("uuid"),
Json::object({ "format": Json::string("uuid") }),
explicit_message=!msg.is_empty(),
)
}
///|
/// Require the string to be a valid CUID (25 chars, starts with 'c', alphanumeric).
pub fn Schema::cuid(self : Schema, msg~ : String = "") -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("cuid() is only valid for string schemas")
}
let message = if msg.is_empty() { "String must be a valid CUID" } else { msg }
append_builtin_rule(
self,
fn(json) {
match json {
String(s) => {
let chars = s.to_array()
if chars.length() != 25 {
return false
}
if chars[0] != 'c' {
return false
}
for i = 1; i < 25; i = i + 1 {
let c = chars[i]
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
return false
}
}
true
}
_ => false
}
},
message,
IssueCode::InvalidFormat("cuid"),
Json::object({ "format": Json::string("cuid") }),
explicit_message=!msg.is_empty(),
)
}
///|
fn is_leap_year(year : Int32) -> Bool {
year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)
}
///|
///|
fn is_valid_iso8601(
s : String,
offset : Bool,
allow_local : Bool,
precision : Int32,
) -> Bool {
let chars = s.to_array()
let n = chars.length()
if n < 16 || chars[4] != '-' || chars[7] != '-' || chars[10] != 'T' {
return false
}
for i = 0; i < 10; i = i + 1 {
if i != 4 && i != 7 && (chars[i] < '0' || chars[i] > '9') {
return false
}
}
let year = (chars[0].to_int() - 48) * 1000 +
(chars[1].to_int() - 48) * 100 +
(chars[2].to_int() - 48) * 10 +
chars[3].to_int() -
48
let month = (chars[5].to_int() - 48) * 10 + chars[6].to_int() - 48
let day = (chars[8].to_int() - 48) * 10 + chars[9].to_int() - 48
let days = match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31
4 | 6 | 9 | 11 => 30
2 => if is_leap_year(year) { 29 } else { 28 }
_ => 0
}
if day < 1 ||
day > days ||
!valid_clock_pair(chars, 11, 23) ||
chars[13] != ':' ||
!valid_clock_pair(chars, 14, 59) {
return false
}
let mut pos = 16
let has_seconds = pos < n && chars[pos] == ':'
if has_seconds {
if !valid_clock_pair(chars, 17, 59) {
return false
}
pos = 19
}
if precision == -1 && has_seconds {
return false
}
if !has_seconds && (precision >= 0 || (precision != -1 && !allow_local)) {
return false
}
let mut decimals = 0
if pos < n && chars[pos] == '.' {
if !has_seconds {
return false
}
pos = pos + 1
while pos < n && chars[pos] >= '0' && chars[pos] <= '9' {
decimals = decimals + 1
pos = pos + 1
}
if decimals == 0 {
return false
}
}
if precision >= 0 && decimals != precision {
return false
}
if pos == n {
return allow_local
}
if chars[pos] == 'Z' {
return pos + 1 == n && (has_seconds || precision == -1)
}
if !offset ||
(!has_seconds && precision != -1) ||
pos + 6 != n ||
(chars[pos] != '+' && chars[pos] != '-') ||
chars[pos + 3] != ':' {
return false
}
valid_clock_pair(chars, pos + 1, 23) && valid_clock_pair(chars, pos + 4, 59)
}
///|
fn valid_clock_pair(
chars : Array[Char],
start : Int32,
maximum : Int32,
) -> Bool {
if start + 1 >= chars.length() {
return false
}
let a = chars[start]
let b = chars[start + 1]
a >= '0' &&
a <= '9' &&
b >= '0' &&
b <= '9' &&
(a.to_int() - 48) * 10 + b.to_int() - 48 <= maximum
}
///|
/// Require the string to be a valid ISO 8601 datetime.
pub fn Schema::datetime(
self : Schema,
msg~ : String = "",
offset~ : Bool = false,
allow_local~ : Bool = false,
precision~ : Int32 = -2,
) -> Schema {
if precision < -2 {
abort(
"datetime precision must be -2 (default), -1 (minutes), or nonnegative",
)
}
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("datetime() is only valid for string schemas")
}
let message = if msg.is_empty() {
"String must be a valid ISO 8601 datetime"
} else {
msg
}
append_builtin_rule(
self,
fn(json) {
match json {
String(s) => is_valid_iso8601(s, offset, allow_local, precision)
_ => false
}
},
message,
IssueCode::InvalidFormat("datetime"),
Json::object({
"format": Json::string("date-time"),
"x-moon-zod-datetime": Json::object({
"offset": Json::boolean(offset),
"allow_local": Json::boolean(allow_local),
"precision": Json::number(precision.to_double()),
}),
}),
explicit_message=!msg.is_empty(),
)
}
///|
fn is_valid_ipv4(s : String) -> Bool {
let chars = s.to_array()
let n = chars.length()
if n < 7 || n > 15 {
return false
}
// Count dots
let mut dot_count = 0
for c in chars {
if c == '.' {
dot_count = dot_count + 1
}
}
if dot_count != 3 {
return false
}
// Parse 4 segments
let mut seg_start = 0
for seg = 0; seg < 4; seg = seg + 1 {
// Find end of this segment
let mut seg_end = n
for i = seg_start; i < n; i = i + 1 {
if chars[i] == '.' {
seg_end = i
break
}
}
let len = seg_end - seg_start
if len < 1 || len > 3 {
return false
}
// No leading zeros
if len > 1 && chars[seg_start] == '0' {
return false
}
// Parse digits
let mut val = 0
for i = seg_start; i < seg_end; i = i + 1 {
if chars[i] < '0' || chars[i] > '9' {
return false
}
val = val * 10 + (chars[i].to_int() - 48)
}
if val > 255 {
return false
}
seg_start = seg_end + 1
}
true
}
///|
fn is_valid_ipv6(s : String) -> Bool {
let segments = s.split(":").to_array()
if segments.length() < 3 {
return false
}
let chars = s.to_array()
let mut compression = 0
let mut i = 0
while i + 1 < chars.length() {
if chars[i] == ':' && chars[i + 1] == ':' {
compression = compression + 1
i = i + 2
if i < chars.length() && chars[i] == ':' {
return false
}
} else {
i = i + 1
}
}
if compression > 1 {
return false
}
if compression == 0 && (s.has_prefix(":") || s.has_suffix(":")) {
return false
}
if s.has_prefix(":") && !s.has_prefix("::") {
return false
}
if s.has_suffix(":") && !s.has_suffix("::") {
return false
}
let mut groups = 0
for i = 0; i < segments.length(); i = i + 1 {
let segment = segments[i]
if segment.is_empty() {
continue
}
if segment.contains(".") {
if i != segments.length() - 1 || !is_valid_ipv4(segment.to_owned()) {
return false
}
groups = groups + 2
} else {
if segment.length() > 4 || !segment.to_array().all(is_hex_digit) {
return false
}
groups = groups + 1
}
}
if compression == 1 {
groups < 8
} else {
groups == 8
}
}
///|
/// Require the string to be a valid IPv4 address.
pub fn Schema::ipv4(self : Schema, msg~ : String = "") -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("ipv4() is only valid for string schemas")
}
let message = if msg.is_empty() {
"String must be a valid IPv4 address"
} else {
msg
}
append_builtin_rule(
self,
fn(json) {
match json {
String(s) => is_valid_ipv4(s)
_ => false
}
},
message,
IssueCode::InvalidFormat("ipv4"),
Json::object({ "format": Json::string("ipv4") }),
explicit_message=!msg.is_empty(),
)
}
///|
/// Require the string to be a valid IPv6 address.
pub fn Schema::ipv6(self : Schema, msg~ : String = "") -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("ipv6() is only valid for string schemas")
}
let message = if msg.is_empty() {
"String must be a valid IPv6 address"
} else {
msg
}
append_builtin_rule(
self,
fn(json) {
match json {
String(s) => is_valid_ipv6(s)
_ => false
}
},
message,
IssueCode::InvalidFormat("ipv6"),
Json::object({ "format": Json::string("ipv6") }),
explicit_message=!msg.is_empty(),
)
}
///|
/// Require the string to be a valid IPv4 or IPv6 address.
pub fn Schema::ip(self : Schema, msg~ : String = "") -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("ip() is only valid for string schemas")
}
let message = if msg.is_empty() {
"String must be a valid IP address"
} else {
msg
}
append_builtin_rule(
self,
fn(json) {
match json {
String(s) => is_valid_ipv4(s) || is_valid_ipv6(s)
_ => false
}
},
message,
IssueCode::InvalidFormat("ip"),
Json::object({
"anyOf": Json::array([
Json::object({ "format": Json::string("ipv4") }),
Json::object({ "format": Json::string("ipv6") }),
]),
}),
constraint=Some(Format("ip")),
explicit_message=!msg.is_empty(),
)
}
///|
fn schema_length_check(s : Schema, n : Int32) -> (Json) -> Bool {
match inner_type(s.schema_type) {
StringType =>
fn(json) {
match json {
String(s) => ecma_code_point_length(s) == n
_ => false
}
}
ArrayType(_) =>
fn(json) {
match json {
Array(arr) => arr.length() == n
_ => false
}
}
_ => fn(_) { false }
}
}
///|
fn schema_length_msg(s : Schema, n : Int32) -> String {
match inner_type(s.schema_type) {
StringType => "String must contain exactly \{n} character(s)"
ArrayType(_) => "Array must contain exactly \{n} item(s)"
_ => abort("length() is only valid for string or array schemas")
}
}
///|
/// Require the string to be non-empty (or array to be non-empty).
pub fn Schema::nonempty(self : Schema, msg~ : String = "") -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
ArrayType(_) => ()
TupleType(_) => ()
_ => abort("nonempty() is only valid for string, array, or tuple schemas")
}
let default_msg = match inner_type(self.schema_type) {
StringType => "String must not be empty"
_ => "Array must not be empty"
}
let message = if msg.is_empty() { default_msg } else { msg }
let origin = match inner_type(self.schema_type) {
StringType => "string"
ArrayType(_) => "array"
TupleType(_) => "tuple"
_ => "value"
}
append_builtin_rule(
self,
fn(json) {
match json {
String(s) => !s.is_empty()
Array(arr) => !arr.is_empty()
_ => false
}
},
message,
IssueCode::TooSmall(origin, 1.0, true),
if origin == "string" {
Json::object({ "minLength": Json::number(1.0) })
} else {
Json::object({ "minItems": Json::number(1.0) })
},
explicit_message=!msg.is_empty(),
)
}
///|
/// Trim leading and trailing whitespace from the string.
pub fn Schema::trim(self : Schema) -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("trim() is only valid for string schemas")
}
{
id: new_schema_id(),
schema_type: TransformType(self, TransformClosure::{
f: fn(json) {
match json {
String(s) => Ok(Json::string(ecma_trim(s)))
_ => Err("Expected string")
}
},
kind: TrimTransform,
}),
rules: [],
description: self.description,
required_error: self.required_error,
invalid_type_error: self.invalid_type_error,
name: self.name,
brand: self.brand,
}
}
///|
/// Convert the string to lowercase.
pub fn Schema::to_lower(self : Schema) -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("to_lower() is only valid for string schemas")
}
{
id: new_schema_id(),
schema_type: TransformType(self, TransformClosure::{
f: fn(json) {
match json {
String(s) => Ok(Json::string(ecma_case_convert(s, false)))
_ => Err("Expected string")
}
},
kind: LowerTransform,
}),
rules: [],
description: self.description,
required_error: self.required_error,
invalid_type_error: self.invalid_type_error,
name: self.name,
brand: self.brand,
}
}
///|
/// Convert the string to uppercase.
pub fn Schema::to_upper(self : Schema) -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("to_upper() is only valid for string schemas")
}
{
id: new_schema_id(),
schema_type: TransformType(self, TransformClosure::{
f: fn(json) {
match json {
String(s) => Ok(Json::string(ecma_case_convert(s, true)))
_ => Err("Expected string")
}
},
kind: UpperTransform,
}),
rules: [],
description: self.description,
required_error: self.required_error,
invalid_type_error: self.invalid_type_error,
name: self.name,
brand: self.brand,
}
}
///|
/// Require the string or array to have exactly `n` items.
pub fn Schema::length(self : Schema, n : Int32, msg~ : String = "") -> Schema {
guard_no_user_transform(self)
if n < 0 {
abort("length() requires a nonnegative length")
}
let check = schema_length_check(self, n)
let message = if msg.is_empty() { schema_length_msg(self, n) } else { msg }
let annotation = match inner_type(self.schema_type) {
StringType =>
Json::object({
"minLength": Json::number(n.to_double()),
"maxLength": Json::number(n.to_double()),
})
ArrayType(_) =>
Json::object({
"minItems": Json::number(n.to_double()),
"maxItems": Json::number(n.to_double()),
})
_ => abort("length() requires a string or array")
}
append_builtin_rule(
self,
check,
message,
IssueCode::Custom,
annotation,
explicit_message=!msg.is_empty(),
)
}
///|
fn is_crockford_base32(c : Char) -> Bool {
let c = c.to_ascii_uppercase()
(c >= '0' && c <= '9') ||
(c >= 'A' && c <= 'Z' && c != 'I' && c != 'L' && c != 'O' && c != 'U')
}
///|
/// Require the string to be a valid ULID (26 chars, Crockford Base32).
pub fn Schema::ulid(self : Schema, msg~ : String = "") -> Schema {
guard_no_user_transform(self)
match inner_type(self.schema_type) {
StringType => ()
_ => abort("ulid() is only valid for string schemas")
}
let message = if msg.is_empty() { "String must be a valid ULID" } else { msg }
append_builtin_rule(
self,
fn(json) {
match json {
String(s) => {
let chars = s.to_array()
if chars.length() != 26 {
return false
}
// First char must be 0-7 (timestamp high bits)
if chars[0] < '0' || chars[0] > '7' {
return false
}
for c in chars {
if !is_crockford_base32(c) {
return false
}
}
true
}
_ => false
}
},
message,
IssueCode::InvalidFormat("ulid"),
Json::object({ "format": Json::string("ulid") }),
explicit_message=!msg.is_empty(),
)
}
///|
fn guard_sized_bound(schema : Schema, bound : Double) -> Unit {
if bound.is_nan() || bound.is_inf() {
abort("bounds must be finite")
}
match inner_type(schema.schema_type) {
NumberType => ()
StringType | ArrayType(_) =>
if bound < 0.0 || bound != bound.floor() {
abort("length bounds must be nonnegative integers")
}
_ => abort("min()/max() require a string, number or array schema")
}
}