// retoor <retoor@molodetz.nl>
import "regex" for Regex
import "base64" for Base64
import "json" for Json
import "uuid" for Uuid
class Validator {
static email(value) {
if (!(value is String)) return false
var at = value.indexOf("@")
if (at < 1) return false
var dot = value.indexOf(".", at)
if (dot < at + 2) return false
if (dot >= value.count - 1) return false
for (c in value) {
var code = c.codePoints.toList[0]
if (c == "@" || c == "." || c == "_" || c == "-" || c == "+") continue
if (code >= 48 && code <= 57) continue
if (code >= 65 && code <= 90) continue
if (code >= 97 && code <= 122) continue
return false
}
return true
}
static domain(value) {
if (!(value is String)) return false
if (value.count == 0) return false
if (value[0] == "-" || value[-1] == "-") return false
var parts = value.split(".")
if (parts.count < 2) return false
for (part in parts) {
if (part.count == 0 || part.count > 63) return false
if (part[0] == "-" || part[-1] == "-") return false
for (c in part) {
var code = c.codePoints.toList[0]
if (c == "-") continue
if (code >= 48 && code <= 57) continue
if (code >= 65 && code <= 90) continue
if (code >= 97 && code <= 122) continue
return false
}
}
return true
}
static safeStr(value) {
if (!(value is String)) return false
for (b in value.bytes) {
if (b < 32 || b > 126) return false
}
return true
}
static httpEncoded(value) {
if (!(value is String)) return false
for (c in value) {
var code = c.codePoints.toList[0]
if (c == "_" || c == "." || c == "~" || c == "-" || c == "+") continue
if (c == "\%") continue
if (code >= 48 && code <= 57) continue
if (code >= 65 && code <= 90) continue
if (code >= 97 && code <= 122) continue
return false
}
return true
}
static base64(value) {
if (!(value is String)) return false
if (value.count % 4 != 0) return false
var padCount = 0
for (i in 0...value.count) {
var c = value[i]
if (c == "=") {
padCount = padCount + 1
if (i < value.count - 2) return false
} else if (padCount > 0) {
return false
} else {
var code = c.codePoints.toList[0]
if (c == "+" || c == "/") continue
if (code >= 48 && code <= 57) continue
if (code >= 65 && code <= 90) continue
if (code >= 97 && code <= 122) continue
return false
}
}
return padCount <= 2
}
static json(value) {
if (!(value is String)) return false
var fiber = Fiber.new {
Json.parse(value)
}
var result = fiber.try()
return !fiber.error
}
static regex(value, pattern) {
if (!(value is String)) return false
var re = Regex.new(pattern)
return re.test(value)
}
static url(value) {
if (!(value is String)) return false
if (!value.startsWith("http://") && !value.startsWith("https://")) return false
var rest = value.startsWith("https://") ? value[8..-1] : value[7..-1]
if (rest.count == 0) return false
return true
}
static uuid(value) {
return Uuid.isValid(value)
}
static minLength(value, min) {
if (value is String) return value.count >= min
if (value is List) return value.count >= min
return false
}
static maxLength(value, max) {
if (value is String) return value.count <= max
if (value is List) return value.count <= max
return false
}
static range(value, min, max) {
if (!(value is Num)) return false
return value >= min && value <= max
}
static positive(value) {
if (!(value is Num)) return false
return value > 0
}
static negative(value) {
if (!(value is Num)) return false
return value < 0
}
static integer(value) {
if (!(value is Num)) return false
return value == value.floor
}
static ipv4(value) {
if (!(value is String)) return false
var parts = value.split(".")
if (parts.count != 4) return false
for (part in parts) {
if (part.count == 0 || part.count > 3) return false
var n = Num.fromString(part)
if (n == null || n < 0 || n > 255) return false
if (part.count > 1 && part[0] == "0") return false
}
return true
}
}
class Field {
static string() { StringField.new() }
static string(options) { StringField.new(options) }
static integer() { IntegerField.new() }
static integer(options) { IntegerField.new(options) }
static number() { NumberField.new() }
static number(options) { NumberField.new(options) }
static email() { EmailField.new() }
static boolean() { BooleanField.new() }
static list(itemType) { ListField.new(itemType) }
static map() { MapField.new() }
static optional(fieldDef) { OptionalField.new(fieldDef) }
static required(fieldDef) {
fieldDef.required = true
return fieldDef
}
}
class BaseField {
construct new() {
_required = true
_default = null
_validators = []
}
construct new(options) {
_required = options.containsKey("required") ? options["required"] : true
_default = options.containsKey("default") ? options["default"] : null
_validators = options.containsKey("validators") ? options["validators"] : []
}
required { _required }
required=(value) { _required = value }
default { _default }
validators { _validators }
addValidator(fn) {
_validators.add(fn)
return this
}
validate(value, path) {
if (value == null) {
if (_required) return ValidationError.new(path, "Field is required")
return null
}
var typeError = validateType_(value, path)
if (typeError) return typeError
for (validator in _validators) {
if (!validator.call(value)) {
return ValidationError.new(path, "Custom validation failed")
}
}
return null
}
validateType_(value, path) { null }
coerce(value) { value }
}
class StringField is BaseField {
construct new() {
super()
_minLength = null
_maxLength = null
_pattern = null
}
construct new(options) {
super(options)
_minLength = options.containsKey("minLength") ? options["minLength"] : null
_maxLength = options.containsKey("maxLength") ? options["maxLength"] : null
_pattern = options.containsKey("pattern") ? options["pattern"] : null
}
validateType_(value, path) {
if (!(value is String)) return ValidationError.new(path, "Expected string")
if (_minLength && value.count < _minLength) {
return ValidationError.new(path, "String too short")
}
if (_maxLength && value.count > _maxLength) {
return ValidationError.new(path, "String too long")
}
if (_pattern) {
var re = Regex.new(_pattern)
if (!re.test(value)) {
return ValidationError.new(path, "String does not match pattern")
}
}
return null
}
minLength { _minLength }
maxLength { _maxLength }
}
class IntegerField is BaseField {
construct new() {
super()
_min = null
_max = null
}
construct new(options) {
super(options)
_min = options.containsKey("min") ? options["min"] : null
_max = options.containsKey("max") ? options["max"] : null
}
validateType_(value, path) {
if (!(value is Num)) return ValidationError.new(path, "Expected integer")
if (value != value.floor) return ValidationError.new(path, "Expected integer, got float")
if (_min && value < _min) return ValidationError.new(path, "Value below minimum")
if (_max && value > _max) return ValidationError.new(path, "Value above maximum")
return null
}
min { _min }
max { _max }
coerce(value) {
if (value is String) return Num.fromString(value)
return value
}
}
class NumberField is BaseField {
construct new() {
super()
_min = null
_max = null
}
construct new(options) {
super(options)
_min = options.containsKey("min") ? options["min"] : null
_max = options.containsKey("max") ? options["max"] : null
}
validateType_(value, path) {
if (!(value is Num)) return ValidationError.new(path, "Expected number")
if (_min && value < _min) return ValidationError.new(path, "Value below minimum")
if (_max && value > _max) return ValidationError.new(path, "Value above maximum")
return null
}
min { _min }
max { _max }
coerce(value) {
if (value is String) return Num.fromString(value)
return value
}
}
class EmailField is BaseField {
construct new() { super() }
validateType_(value, path) {
if (!(value is String)) return ValidationError.new(path, "Expected string")
if (!Validator.email(value)) return ValidationError.new(path, "Invalid email format")
return null
}
}
class BooleanField is BaseField {
construct new() { super() }
validateType_(value, path) {
if (!(value is Bool)) return ValidationError.new(path, "Expected boolean")
return null
}
coerce(value) {
if (value is String) {
if (value == "true" || value == "1") return true
if (value == "false" || value == "0") return false
}
return value
}
}
class ListField is BaseField {
construct new(itemType) {
super()
_itemType = itemType
}
validateType_(value, path) {
if (!(value is List)) return ValidationError.new(path, "Expected list")
for (i in 0...value.count) {
var itemPath = path + "[" + i.toString + "]"
var error = _itemType.validate(value[i], itemPath)
if (error) return error
}
return null
}
itemType { _itemType }
}
class MapField is BaseField {
construct new() { super() }
validateType_(value, path) {
if (!(value is Map)) return ValidationError.new(path, "Expected map")
return null
}
}
class OptionalField is BaseField {
construct new(fieldDef) {
super()
_field = fieldDef
_required = false
}
validate(value, path) {
if (value == null) return null
return _field.validate(value, path)
}
}
class ValidationError {
construct new(path, message) {
_path = path
_message = message
}
path { _path }
message { _message }
toString { _path + ": " + _message }
}
class ValidationResult {
construct new(isValid, errors, data) {
_isValid = isValid
_errors = errors
_data = data
}
isValid { _isValid }
errors { _errors }
data { _data }
}
class Schema {
construct new(definition) {
_definition = definition
}
validate(data) {
if (!(data is Map)) {
return ValidationResult.new(false, [ValidationError.new("", "Expected object")], null)
}
var errors = []
var validData = {}
for (key in _definition.keys) {
var field = _definition[key]
var value = data.containsKey(key) ? data[key] : null
if (value != null) {
value = field.coerce(value)
}
var error = field.validate(value, key)
if (error) {
errors.add(error)
} else {
validData[key] = value != null ? value : field.default
}
}
return ValidationResult.new(errors.count == 0, errors, errors.count == 0 ? validData : null)
}
validateOrAbort(data) {
var result = validate(data)
if (!result.isValid) {
var messages = []
for (error in result.errors) {
messages.add(error.toString)
}
Fiber.abort("Validation failed: " + messages.join(", "))
}
return result.data
}
}