///|
/// Validation framework — validates struct fields with declarative rules.
///
/// inspired by `binding:"required,min=1"` tags and `validator.v9`.
/// Since MoonBit doesn't have struct tags, validation uses a
/// function-based API: `validate_struct(value, rules)`.
///|
/// A single validation rule for a field.
pub(all) enum Rule {
/// Field must not be empty
Required
/// Minimum string length or numeric value
Min(Int)
/// Maximum string length or numeric value
Max(Int)
/// Exact string length
Len(Int)
/// Must match a regular expression pattern
Pattern(String)
/// Must be one of the allowed values
OneOf(Array[String])
/// Must be a valid email (basic check: contains @)
Email
/// Must be a valid URL (basic check: has scheme)
URL
/// Custom validation function, returns error message or None if valid
Custom((String) -> String?)
}
///|
/// Field validation rules — maps field name to an array of rules.
/// Example:
/// ```
/// let rules = [FieldRules("username", [Required, Min(3), Max(20)]),
/// FieldRules("email", [Required, Email]),
/// FieldRules("age", [Min(0), Max(150)])]
/// ```
pub(all) struct FieldRules {
field : String
rules : Array[Rule]
}
///|
/// Create field rules.
pub fn FieldRules::new(field : String, rules : Array[Rule]) -> FieldRules {
{ field, rules }
}
///|
/// A validation error for a single field.
pub(all) struct ValidationError {
field : String
message : String
} derive(Debug)
///|
/// Collection of validation errors.
pub(all) struct ValidationErrors {
errors : Array[ValidationError]
}
///|
/// Create a new empty ValidationErrors.
pub fn ValidationErrors::new() -> ValidationErrors {
{ errors: [] }
}
///|
/// Add a validation error.
pub fn ValidationErrors::add(self : ValidationErrors, field : String, message : String) -> Unit {
self.errors.push({ field, message })
}
///|
/// All validation errors.
pub fn ValidationErrors::all(self : ValidationErrors) -> Array[ValidationError] {
self.errors
}
///|
/// Whether there are any errors.
pub fn ValidationErrors::has_errors(self : ValidationErrors) -> Bool {
self.errors.length() > 0
}
///|
/// Get the first error message, if any.
pub fn ValidationErrors::first_error(self : ValidationErrors) -> String? {
if self.errors.length() > 0 {
Some(self.errors[0].field + ": " + self.errors[0].message)
} else {
None
}
}
///|
/// Convert validation errors to a JSON response body.
pub fn ValidationErrors::to_json(self : ValidationErrors) -> Json {
let obj : Map[String, Json] = Map([])
for err in self.errors {
obj.set(err.field, Json::string(err.message))
}
Json::object(obj)
}
///|
/// Error string for a ValidationError.
pub fn ValidationError::to_string(self : ValidationError) -> String {
self.field + ": " + self.message
}
///| ——————————————————————————————————————————————————————————————————————
/// Validation functions
///| ——————————————————————————————————————————————————————————————————————
///|
/// Validate a single field value against a list of rules.
/// Returns `Some(error_message)` if validation fails, `None` if valid.
pub fn validate_field(value : String, rules : Array[Rule]) -> String? {
for rule in rules {
match rule {
Required => {
if value == "" {
return Some("is required")
}
}
Min(n) => {
// Try numeric comparison first
try {
let num = @string.parse_int64(value)
if num < n.to_int64() {
return Some("must be at least " + n.to_string())
}
} catch {
_ =>
if value.length() < n {
return Some("must be at least " + n.to_string() + " characters")
}
}
}
Max(n) =>
try {
let num = @string.parse_int64(value)
if num > n.to_int64() {
return Some("must be at most " + n.to_string())
}
} catch {
_ =>
if value.length() > n {
return Some("must be at most " + n.to_string() + " characters")
}
}
Len(n) => {
if value.length() != n {
return Some("must be exactly " + n.to_string() + " characters")
}
}
Pattern(pattern) => {
// Simple pattern matching: check if value contains the pattern
if value.find(pattern) is None && value != pattern {
return Some("does not match pattern " + pattern)
}
}
OneOf(allowed) => {
let mut found = false
for a in allowed {
if a == value {
found = true
break
}
}
if !found {
return Some("must be one of the allowed values")
}
}
Email => {
if value.find("@") is None || value.find(".") is None {
return Some("must be a valid email address")
}
let at_pos = match value.find("@") {
Some(p) => p
None => {
// unreachable due to check above, but handle gracefully
return Some("must be a valid email address")
}
}
if at_pos == 0 || at_pos == value.length() - 1 {
return Some("must be a valid email address")
}
}
URL => {
if !(value.has_prefix("http://") || value.has_prefix("https://")) {
return Some("must be a valid URL (http:// or https://)")
}
}
Custom(f) =>
match f(value) {
Some(msg) => return Some(msg)
None => ()
}
}
}
None
}
///|
/// Validate a map of field values against field rules.
/// Returns `ValidationErrors` containing all validation failures.
///
/// ```
/// let values = Map([("username", "alice"), ("email", "alice@example.com")])
/// let rules = [
/// FieldRules::new("username", [Required, Min(3)]),
/// FieldRules::new("email", [Required, Email]),
/// ]
/// let errors = validate_map(values, rules)
/// if errors.has_errors() {
/// ctx.json(400, errors.to_json())
/// return
/// }
/// ```
pub fn validate_map(
values : Map[String, String],
field_rules : Array[FieldRules],
) -> ValidationErrors {
let errors = ValidationErrors::new()
for fr in field_rules {
let value = match values.get(fr.field) {
Some(v) => v
None => ""
}
match validate_field(value, fr.rules) {
Some(msg) => errors.add(fr.field, msg)
None => ()
}
}
errors
}
///|
/// Convenience: validate and abort with 422 if there are errors.
/// Returns `true` if valid, `false` if aborted.
///
/// ```
/// let values = Map([("username", "alice")])
/// let rules = [FieldRules::new("username", [Required, Min(3)])]
/// if !validate_or_abort(ctx, values, rules) {
/// return
/// }
/// // Proceed with valid data...
/// ```
pub async fn validate_or_abort(
ctx : Context,
values : Map[String, String],
rules : Array[FieldRules],
) -> Bool {
let errors = validate_map(values, rules)
if errors.has_errors() {
ctx.abort_with_status(422, match errors.first_error() {
Some(e) => e
None => "Validation failed"
})
return false
}
true
}