Skip to main content

src/gflare/validate.gleam

import gflare/request.{type HttpRequest}
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/int
import gleam/javascript/promise.{type Promise}
import gleam/json
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/string

pub type Schema(a) {
  Schema(
    decode: fn(Dynamic) -> Result(a, List(ValidationError)),
    field_name: String,
  )
}

pub type ValidationError {
  ValidationError(field: String, message: String)
}

// Basic type schemas

/// Create a string schema for the given field.
pub fn string(field: String) -> Schema(String) {
  Schema(
    decode: fn(data) {
      case decode.run(data, decode.string) {
        Ok(value) -> Ok(value)
        Error(_) -> Error([ValidationError(field, "Expected a string")])
      }
    },
    field_name: field,
  )
}

/// Create an integer schema for the given field.
pub fn int(field: String) -> Schema(Int) {
  Schema(
    decode: fn(data) {
      case decode.run(data, decode.int) {
        Ok(value) -> Ok(value)
        Error(_) -> Error([ValidationError(field, "Expected an integer")])
      }
    },
    field_name: field,
  )
}

pub fn float(field: String) -> Schema(Float) {
  Schema(
    decode: fn(data) {
      case decode.run(data, decode.float) {
        Ok(value) -> Ok(value)
        Error(_) -> Error([ValidationError(field, "Expected a float")])
      }
    },
    field_name: field,
  )
}

pub fn bool(field: String) -> Schema(Bool) {
  Schema(
    decode: fn(data) {
      case decode.run(data, decode.bool) {
        Ok(value) -> Ok(value)
        Error(_) -> Error([ValidationError(field, "Expected a boolean")])
      }
    },
    field_name: field,
  )
}

// Container schemas

pub fn required(field: String, schema: Schema(a)) -> Schema(a) {
  Schema(
    decode: fn(data) {
      case dynamic.classify(data) {
        "Nil" -> Error([ValidationError(field, "Field is required")])
        _ -> schema.decode(data)
      }
    },
    field_name: field,
  )
}

pub fn optional(field: String, schema: Schema(a)) -> Schema(Option(a)) {
  Schema(
    decode: fn(data) {
      case dynamic.classify(data) {
        "Nil" -> Ok(None)
        _ -> {
          case schema.decode(data) {
            Ok(value) -> Ok(Some(value))
            Error(errors) -> Error(errors)
          }
        }
      }
    },
    field_name: field,
  )
}

// String validators

pub fn min_length(schema: Schema(String), length: Int) -> Schema(String) {
  let field = schema.field_name
  Schema(
    decode: fn(data) {
      case schema.decode(data) {
        Ok(value) -> {
          case string.length(value) >= length {
            True -> Ok(value)
            False ->
              Error([
                ValidationError(
                  field,
                  "Must be at least " <> int.to_string(length) <> " characters",
                ),
              ])
          }
        }
        Error(errors) -> Error(errors)
      }
    },
    field_name: field,
  )
}

pub fn max_length(schema: Schema(String), length: Int) -> Schema(String) {
  let field = schema.field_name
  Schema(
    decode: fn(data) {
      case schema.decode(data) {
        Ok(value) -> {
          case string.length(value) <= length {
            True -> Ok(value)
            False ->
              Error([
                ValidationError(
                  field,
                  "Must be at most " <> int.to_string(length) <> " characters",
                ),
              ])
          }
        }
        Error(errors) -> Error(errors)
      }
    },
    field_name: field,
  )
}

pub fn pattern(schema: Schema(String), pattern_str: String) -> Schema(String) {
  let field = schema.field_name
  Schema(
    decode: fn(data) {
      case schema.decode(data) {
        Ok(value) -> {
          case matches_pattern(value, pattern_str) {
            True -> Ok(value)
            False -> Error([ValidationError(field, "Invalid format")])
          }
        }
        Error(errors) -> Error(errors)
      }
    },
    field_name: field,
  )
}

// Number validators

pub fn min(schema: Schema(Int), min_val: Int) -> Schema(Int) {
  let field = schema.field_name
  Schema(
    decode: fn(data) {
      case schema.decode(data) {
        Ok(n) -> {
          case n >= min_val {
            True -> Ok(n)
            False ->
              Error([
                ValidationError(
                  field,
                  "Must be at least " <> int.to_string(min_val),
                ),
              ])
          }
        }
        Error(errors) -> Error(errors)
      }
    },
    field_name: field,
  )
}

pub fn max(schema: Schema(Int), max_val: Int) -> Schema(Int) {
  let field = schema.field_name
  Schema(
    decode: fn(data) {
      case schema.decode(data) {
        Ok(n) -> {
          case n <= max_val {
            True -> Ok(n)
            False ->
              Error([
                ValidationError(
                  field,
                  "Must be at most " <> int.to_string(max_val),
                ),
              ])
          }
        }
        Error(errors) -> Error(errors)
      }
    },
    field_name: field,
  )
}

pub fn between(schema: Schema(Int), min_val: Int, max_val: Int) -> Schema(Int) {
  let field = schema.field_name
  Schema(
    decode: fn(data) {
      case schema.decode(data) {
        Ok(n) -> {
          case n >= min_val && n <= max_val {
            True -> Ok(n)
            False ->
              Error([
                ValidationError(
                  field,
                  "Must be between "
                    <> int.to_string(min_val)
                    <> " and "
                    <> int.to_string(max_val),
                ),
              ])
          }
        }
        Error(errors) -> Error(errors)
      }
    },
    field_name: field,
  )
}

pub fn positive(schema: Schema(Int)) -> Schema(Int) {
  min(schema, 1)
}

pub fn negative(schema: Schema(Int)) -> Schema(Int) {
  let field = schema.field_name
  Schema(
    decode: fn(data) {
      case schema.decode(data) {
        Ok(n) -> {
          case n < 0 {
            True -> Ok(n)
            False -> Error([ValidationError(field, "Must be negative")])
          }
        }
        Error(errors) -> Error(errors)
      }
    },
    field_name: field,
  )
}

// Common validation helpers

pub fn email(field: String) -> Schema(String) {
  string(field)
  |> pattern("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
}

pub fn url(field: String) -> Schema(String) {
  string(field) |> pattern("^https?://[^\\s/$.?#].[^\\s]*$")
}

pub fn uuid(field: String) -> Schema(String) {
  string(field)
  |> pattern("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
}

pub fn ulid(field: String) -> Schema(String) {
  string(field) |> pattern("^[0-9A-HJKMNP-TV-Z]{26}$")
}

pub fn date(field: String) -> Schema(String) {
  string(field) |> pattern("^\\d{4}-\\d{2}-\\d{2}$")
}

pub fn time(field: String) -> Schema(String) {
  string(field) |> pattern("^\\d{2}:\\d{2}:\\d{2}$")
}

pub fn datetime(field: String) -> Schema(String) {
  string(field) |> pattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}")
}

pub fn one_of(field: String, values: List(String)) -> Schema(String) {
  Schema(
    decode: fn(data) {
      case decode.run(data, decode.string) {
        Ok(value) -> {
          case list.contains(values, value) {
            True -> Ok(value)
            False ->
              Error([
                ValidationError(
                  field,
                  "Must be one of: " <> string.join(values, ", "),
                ),
              ])
          }
        }
        Error(_) -> Error([ValidationError(field, "Expected a string")])
      }
    },
    field_name: field,
  )
}

// Custom validators

pub fn custom(
  schema: Schema(a),
  validator: fn(a) -> Result(a, String),
) -> Schema(a) {
  let field = schema.field_name
  Schema(
    decode: fn(data) {
      case schema.decode(data) {
        Ok(value) -> {
          case validator(value) {
            Ok(valid) -> Ok(valid)
            Error(msg) -> Error([ValidationError(field, msg)])
          }
        }
        Error(errors) -> Error(errors)
      }
    },
    field_name: field,
  )
}

// Validation functions

/// Validate data against a schema. Returns Ok(value) or Error(list of validation errors).
pub fn validate(
  schema: Schema(a),
  data: Dynamic,
) -> Result(a, List(ValidationError)) {
  schema.decode(data)
}

/// Validate request body against a schema. Returns Ok(value) or Error(list of validation errors).
pub fn validate_body(
  schema: Schema(a),
  request: HttpRequest,
) -> Promise(Result(a, List(ValidationError))) {
  use body <- promise.await(request.json(request))
  case body {
    Ok(data) -> promise.resolve(validate(schema, data))
    Error(_) ->
      promise.resolve(Error([ValidationError("body", "Invalid JSON")]))
  }
}

// Error formatting

/// Format validation errors as JSON for API responses.
pub fn format_errors(errors: List(ValidationError)) -> json.Json {
  json.array(errors, fn(e) {
    json.object([
      #("field", json.string(e.field)),
      #("message", json.string(e.message)),
    ])
  })
}

pub fn errors_to_string(errors: List(ValidationError)) -> String {
  let error_strings = list.map(errors, fn(e) { e.field <> ": " <> e.message })
  string.join(error_strings, "; ")
}

// Internal helpers

fn matches_pattern(value: String, pattern_str: String) -> Bool {
  do_matches_pattern(value, pattern_str)
}

@external(javascript, "../gflare_ffi_validate.mjs", "matches_pattern")
fn do_matches_pattern(value: String, pattern: String) -> Bool