|
// 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
|
|
}
|
|
}
|