Packages

Type-safe JSON Schema generation and validation for Gleam

Current section

Files

Jump to
sextant src sextant.gleam
Raw

src/sextant.gleam

//// Sextant - A Gleam library for JSON Schema generation and validation.
////
//// This library provides a `use`-based API for defining JSON schemas that can
//// both generate JSON Schema 2020-12 documents and validate dynamic data.
////
//// ## Example
////
//// ```gleam
//// import sextant
//// import gleam/option.{type Option}
////
//// type User {
//// User(name: String, age: Int, email: Option(String))
//// }
////
//// fn user_schema() -> sextant.JsonSchema(User) {
//// use name <- sextant.field("name", sextant.string() |> sextant.min_length(1))
//// use age <- sextant.field("age", sextant.integer() |> sextant.int_min(0))
//// use email <- sextant.optional_field("email", sextant.string())
//// sextant.success(User(name:, age:, email:))
//// }
////
//// // Generate JSON Schema
//// let schema_json = sextant.to_json(user_schema())
////
//// // Validate data
//// let result = sextant.run(dynamic_data, user_schema())
//// ```
import gleam/dict.{type Dict}
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/float
import gleam/int
import gleam/json
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/regexp
import gleam/result
import gleam/string
import gleam/time/timestamp.{type Timestamp}
import gleam/uri
import youid/uuid.{type Uuid}
// ---------------------------------------------------------------------------
// Schema Definition Types (internal)
// ---------------------------------------------------------------------------
/// Schema definition that maps to JSON Schema structure
type SchemaDefinition {
StringSchema(constraints: StringConstraints, meta: Metadata)
IntegerSchema(constraints: IntConstraints, meta: Metadata)
NumberSchema(constraints: FloatConstraints, meta: Metadata)
BooleanSchema(meta: Metadata)
NullSchema(meta: Metadata)
ArraySchema(
items: SchemaDefinition,
constraints: ArrayConstraints,
meta: Metadata,
)
ObjectSchema(
properties: List(Property),
required: List(String),
additional_properties: Bool,
meta: Metadata,
)
DictSchema(values: SchemaDefinition, meta: Metadata)
NullableSchema(inner: SchemaDefinition, meta: Metadata)
OneOfSchema(variants: List(SchemaDefinition), meta: Metadata)
AnyOfSchema(variants: List(SchemaDefinition), meta: Metadata)
EnumSchema(values: List(String), meta: Metadata)
ConstSchema(value: json.Json, meta: Metadata)
TupleSchema(items: List(SchemaDefinition), meta: Metadata)
}
/// Object property definition
type Property {
Property(name: String, schema: SchemaDefinition)
}
/// Schema metadata (title, description, etc.)
type Metadata {
Metadata(
title: Option(String),
description: Option(String),
examples: List(json.Json),
default: Option(json.Json),
)
}
const empty_metadata = Metadata(
title: None,
description: None,
examples: [],
default: None,
)
/// String-specific constraints
type StringConstraints {
StringConstraints(
min_length: Option(Int),
max_length: Option(Int),
pattern: Option(String),
format: Option(StringFormat),
)
}
const empty_string_constraints = StringConstraints(
min_length: None,
max_length: None,
pattern: None,
format: None,
)
/// Integer constraints
type IntConstraints {
IntConstraints(
minimum: Option(Int),
maximum: Option(Int),
exclusive_minimum: Option(Int),
exclusive_maximum: Option(Int),
multiple_of: Option(Int),
)
}
const empty_int_constraints = IntConstraints(
minimum: None,
maximum: None,
exclusive_minimum: None,
exclusive_maximum: None,
multiple_of: None,
)
/// Float/number constraints
type FloatConstraints {
FloatConstraints(
minimum: Option(Float),
maximum: Option(Float),
exclusive_minimum: Option(Float),
exclusive_maximum: Option(Float),
multiple_of: Option(Float),
)
}
const empty_float_constraints = FloatConstraints(
minimum: None,
maximum: None,
exclusive_minimum: None,
exclusive_maximum: None,
multiple_of: None,
)
/// Array constraints
type ArrayConstraints {
ArrayConstraints(
min_items: Option(Int),
max_items: Option(Int),
unique_items: Bool,
)
}
const empty_array_constraints = ArrayConstraints(
min_items: None,
max_items: None,
unique_items: False,
)
// ---------------------------------------------------------------------------
// Public Types
// ---------------------------------------------------------------------------
/// String format types as defined in JSON Schema.
pub type StringFormat {
Email
Uri
DateTime
Date
Time
Uuid
Hostname
Ipv4
Ipv6
}
/// String-specific constraint violations.
pub type StringConstraintViolation {
StringTooShort(min: Int, actual: Int)
StringTooLong(max: Int, actual: Int)
PatternMismatch(pattern: String, actual: String)
InvalidPattern(pattern: String, error: String)
InvalidFormat(format: String, actual: String)
}
/// Number-specific constraint violations (applies to both Int and Float).
pub type NumberConstraintViolation {
NumberTooSmall(minimum: Float, exclusive: Bool, actual: Float)
NumberTooLarge(maximum: Float, exclusive: Bool, actual: Float)
NotMultipleOf(multiple: Float, actual: Float)
}
/// Array-specific constraint violations.
pub type ArrayConstraintViolation {
ArrayTooShort(min: Int, actual: Int)
ArrayTooLong(max: Int, actual: Int)
ItemsNotUnique
}
/// Union type for all constraint violations.
pub type ConstraintViolation {
StringViolation(StringConstraintViolation)
NumberViolation(NumberConstraintViolation)
ArrayViolation(ArrayConstraintViolation)
/// Custom validation error from [`try_map`](#try_map) or other user-defined validations.
CustomViolation(message: String)
}
/// Top-level validation error returned when schema validation fails.
pub type ValidationError {
TypeError(expected: String, found: String, path: List(String))
ConstraintError(violation: ConstraintViolation, path: List(String))
MissingField(field: String, path: List(String))
UnknownVariant(value: String, expected: List(String), path: List(String))
ConstMismatch(expected: String, actual: String, path: List(String))
}
/// Convert a validation error to a human-readable string.
pub fn error_to_string(error: ValidationError) -> String {
case error {
TypeError(expected, found, path) ->
"Expected " <> expected <> ", got " <> found <> format_path(path)
ConstraintError(violation, path) ->
constraint_violation_to_string(violation) <> format_path(path)
MissingField(field, path) ->
"Missing required field '" <> field <> "'" <> format_path(path)
UnknownVariant(value, expected, path) ->
"Unknown variant '"
<> value
<> "', expected one of: "
<> string.join(expected, ", ")
<> format_path(path)
ConstMismatch(expected, actual, path) ->
"Expected const value "
<> expected
<> ", got "
<> actual
<> format_path(path)
}
}
fn format_path(path: List(String)) -> String {
case path {
[] -> ""
_ -> " at '" <> string.join(path, ".") <> "'"
}
}
fn constraint_violation_to_string(violation: ConstraintViolation) -> String {
case violation {
StringViolation(v) -> string_violation_to_string(v)
NumberViolation(v) -> number_violation_to_string(v)
CustomViolation(msg) -> msg
ArrayViolation(v) -> array_violation_to_string(v)
}
}
fn string_violation_to_string(violation: StringConstraintViolation) -> String {
case violation {
StringTooShort(min, actual) ->
"String too short (minimum: "
<> int.to_string(min)
<> ", got: "
<> int.to_string(actual)
<> ")"
StringTooLong(max, actual) ->
"String too long (maximum: "
<> int.to_string(max)
<> ", got: "
<> int.to_string(actual)
<> ")"
PatternMismatch(pattern, actual) ->
"String '" <> actual <> "' does not match pattern '" <> pattern <> "'"
InvalidPattern(pattern, error) ->
"Invalid regex pattern '" <> pattern <> "': " <> error
InvalidFormat(format_name, actual) ->
"String '" <> actual <> "' is not a valid " <> format_name
}
}
fn number_violation_to_string(violation: NumberConstraintViolation) -> String {
case violation {
NumberTooSmall(minimum, exclusive, actual) -> {
let op = case exclusive {
True -> "greater than"
False -> "at least"
}
"Number must be "
<> op
<> " "
<> float.to_string(minimum)
<> ", got: "
<> float.to_string(actual)
}
NumberTooLarge(maximum, exclusive, actual) -> {
let op = case exclusive {
True -> "less than"
False -> "at most"
}
"Number must be "
<> op
<> " "
<> float.to_string(maximum)
<> ", got: "
<> float.to_string(actual)
}
NotMultipleOf(multiple, actual) ->
"Number "
<> float.to_string(actual)
<> " is not a multiple of "
<> float.to_string(multiple)
}
}
fn array_violation_to_string(violation: ArrayConstraintViolation) -> String {
case violation {
ArrayTooShort(min, actual) ->
"Array too short (minimum: "
<> int.to_string(min)
<> " items, got: "
<> int.to_string(actual)
<> ")"
ArrayTooLong(max, actual) ->
"Array too long (maximum: "
<> int.to_string(max)
<> " items, got: "
<> int.to_string(actual)
<> ")"
ItemsNotUnique -> "Array items are not unique"
}
}
/// Options for controlling schema validation behaviour.
///
/// Use these options with [`run_with_options`](#run_with_options) to customize
/// validation. The default options are available as [`default_options`](#default_options).
///
/// ## Example
///
/// ```gleam
/// // Enable format validation (disabled by default per JSON Schema spec)
/// let opts = sextant.Options(validate_formats: True)
/// sextant.run_with_options(data, email_schema, opts)
/// ```
pub type Options {
Options(
/// Whether to validate string formats (email, uri, etc.).
/// Disabled by default as per JSON Schema specification, which treats
/// formats as annotations rather than assertions.
validate_formats: Bool,
)
}
/// Default validation options.
///
/// - `validate_formats`: `False` (formats are treated as annotations only)
pub const default_options = Options(validate_formats: False)
/// Check if errors contain a type error (vs only constraint errors).
/// Type errors mean we can't run further constraints on the value.
fn has_type_error(errors: List(ValidationError)) -> Bool {
list.any(errors, fn(e) {
case e {
TypeError(_, _, _) -> True
MissingField(_, _) -> True
_ -> False
}
})
}
/// A JSON Schema that can generate schema documents and validate data.
///
/// The type parameter `a` is the Gleam type that this schema decodes to.
pub opaque type JsonSchema(a) {
JsonSchema(
schema: SchemaDefinition,
decoder: fn(Dynamic, Options) -> #(a, List(ValidationError)),
/// A zero/default value used internally for schema extraction.
/// This value is never exposed to users and is only used when we need
/// to call continuations to build the schema structure.
zero: a,
)
}
// ---------------------------------------------------------------------------
// Primitive Schemas
// ---------------------------------------------------------------------------
/// Create a schema for JSON strings.
///
/// ## Example
///
/// ```gleam
/// let name_schema = sextant.string()
/// ```
pub fn string() -> JsonSchema(String) {
JsonSchema(
schema: StringSchema(empty_string_constraints, empty_metadata),
decoder: fn(data, _opts) {
case decode.run(data, decode.string) |> result.replace_error(Nil) {
Ok(s) -> #(s, [])
Error(_) -> #("", [
TypeError("String", dynamic.classify(data), []),
])
}
},
zero: "",
)
}
/// Create a schema for JSON integers.
///
/// ## Example
///
/// ```gleam
/// let age_schema = sextant.integer()
/// ```
pub fn integer() -> JsonSchema(Int) {
JsonSchema(
schema: IntegerSchema(empty_int_constraints, empty_metadata),
decoder: fn(data, _opts) {
case decode.run(data, decode.int) |> result.replace_error(Nil) {
Ok(i) -> #(i, [])
Error(_) -> #(0, [
TypeError("Int", dynamic.classify(data), []),
])
}
},
zero: 0,
)
}
/// Create a schema for JSON numbers (floats).
///
/// ## Example
///
/// ```gleam
/// let price_schema = sextant.number()
/// ```
pub fn number() -> JsonSchema(Float) {
JsonSchema(
schema: NumberSchema(empty_float_constraints, empty_metadata),
decoder: fn(data, _opts) {
// Try float first, then fall back to int and convert
case decode.run(data, decode.float) {
Ok(f) -> #(f, [])
Error(_) ->
case decode.run(data, decode.int) {
Ok(i) -> #(int.to_float(i), [])
Error(_) -> #(0.0, [
TypeError("Float", dynamic.classify(data), []),
])
}
}
},
zero: 0.0,
)
}
/// Create a schema for JSON booleans.
///
/// ## Example
///
/// ```gleam
/// let active_schema = sextant.boolean()
/// ```
pub fn boolean() -> JsonSchema(Bool) {
JsonSchema(
schema: BooleanSchema(empty_metadata),
decoder: fn(data, _opts) {
case decode.run(data, decode.bool) |> result.replace_error(Nil) {
Ok(b) -> #(b, [])
Error(_) -> #(False, [
TypeError("Bool", dynamic.classify(data), []),
])
}
},
zero: False,
)
}
/// Create a schema for JSON null.
///
/// ## Example
///
/// ```gleam
/// let null_schema = sextant.null()
/// ```
pub fn null() -> JsonSchema(Nil) {
JsonSchema(
schema: NullSchema(empty_metadata),
decoder: fn(data, _opts) {
case is_null(data) {
True -> #(Nil, [])
False -> #(Nil, [
TypeError("Null", dynamic.classify(data), []),
])
}
},
zero: Nil,
)
}
// ---------------------------------------------------------------------------
// Typed Schemas (UUID, Timestamp, URI)
// ---------------------------------------------------------------------------
/// Create a schema for UUIDs that decodes to `youid/uuid.Uuid`.
///
/// This validates and parses the string as a UUID, returning the proper
/// `Uuid` type from the `youid` library. Use this instead of
/// `string() |> format(Uuid)` when you want a typed UUID value.
///
/// ## Example
///
/// ```gleam
/// use id <- sextant.field("id", sextant.uuid())
/// sextant.success(User(id:, ...))
/// ```
pub fn uuid() -> JsonSchema(Uuid) {
let zero_uuid = uuid.v4()
JsonSchema(
schema: StringSchema(
StringConstraints(..empty_string_constraints, format: Some(Uuid)),
empty_metadata,
),
decoder: fn(data, _opts) {
case decode.run(data, decode.string) |> result.replace_error(Nil) {
Ok(s) ->
case uuid.from_string(s) {
Ok(id) -> #(id, [])
Error(_) -> #(zero_uuid, [
ConstraintError(
StringViolation(InvalidFormat(format: "uuid", actual: s)),
[],
),
])
}
Error(_) -> #(zero_uuid, [
TypeError("String", dynamic.classify(data), []),
])
}
},
zero: zero_uuid,
)
}
/// Create a schema for RFC 3339 timestamps that decodes to `gleam/time/timestamp.Timestamp`.
///
/// This validates and parses the string as an RFC 3339 datetime, returning
/// the proper `Timestamp` type from `gleam_time`. Use this instead of
/// `string() |> format(DateTime)` when you want a typed timestamp value.
///
/// ## Example
///
/// ```gleam
/// use created_at <- sextant.field("created_at", sextant.timestamp())
/// sextant.success(Event(created_at:, ...))
/// ```
pub fn timestamp() -> JsonSchema(Timestamp) {
JsonSchema(
schema: StringSchema(
StringConstraints(..empty_string_constraints, format: Some(DateTime)),
empty_metadata,
),
decoder: fn(data, _opts) {
case decode.run(data, decode.string) |> result.replace_error(Nil) {
Ok(s) ->
case timestamp.parse_rfc3339(s) {
Ok(ts) -> #(ts, [])
Error(_) -> #(timestamp.unix_epoch, [
ConstraintError(
StringViolation(InvalidFormat(format: "date-time", actual: s)),
[],
),
])
}
Error(_) -> #(timestamp.unix_epoch, [
TypeError("String", dynamic.classify(data), []),
])
}
},
zero: timestamp.unix_epoch,
)
}
/// Create a schema for URIs that decodes to `gleam/uri.Uri`.
///
/// This validates and parses the string as a URI, returning the proper
/// `Uri` type from `gleam_stdlib`. Use this instead of
/// `string() |> format(Uri)` when you want a typed URI value.
///
/// ## Example
///
/// ```gleam
/// use website <- sextant.optional_field("website", sextant.uri())
/// sextant.success(Profile(website:, ...))
/// ```
pub fn uri() -> JsonSchema(uri.Uri) {
JsonSchema(
schema: StringSchema(
StringConstraints(..empty_string_constraints, format: Some(Uri)),
empty_metadata,
),
decoder: fn(data, _opts) {
case decode.run(data, decode.string) |> result.replace_error(Nil) {
Ok(s) ->
case uri.parse(s) {
Ok(u) ->
// Must have a scheme to be a valid absolute URI
case u.scheme {
Some(_) -> #(u, [])
None -> #(uri.empty, [
ConstraintError(
StringViolation(InvalidFormat(format: "uri", actual: s)),
[],
),
])
}
Error(_) -> #(uri.empty, [
ConstraintError(
StringViolation(InvalidFormat(format: "uri", actual: s)),
[],
),
])
}
Error(_) -> #(uri.empty, [
TypeError("String", dynamic.classify(data), []),
])
}
},
zero: uri.empty,
)
}
// ---------------------------------------------------------------------------
// Object Field Combinators
// ---------------------------------------------------------------------------
/// Finalise a schema with a successfully constructed value.
///
/// This is the terminal function in a use-chain for object schemas.
///
/// ## Example
///
/// ```gleam
/// use name <- sextant.field("name", sextant.string())
/// sextant.success(User(name:))
/// ```
pub fn success(value: a) -> JsonSchema(a) {
JsonSchema(
schema: ObjectSchema(
properties: [],
required: [],
additional_properties: False,
meta: empty_metadata,
),
decoder: fn(_, _) { #(value, []) },
zero: value,
)
}
/// Allow additional properties in the generated JSON Schema.
///
/// By default, object schemas set `additionalProperties: false` to enforce
/// strict validation. Use this combinator to allow extra properties that
/// aren't defined in the schema.
///
/// Note: This only affects the generated JSON Schema document. Sextant's
/// decoder always ignores additional properties during validation.
///
/// ## Example
///
/// ```gleam
/// fn user_schema() -> sextant.JsonSchema(User) {
/// use name <- sextant.field("name", sextant.string())
/// sextant.success(User(name:))
/// }
/// |> sextant.additional_properties(True)
/// ```
pub fn additional_properties(
schema: JsonSchema(a),
allow: Bool,
) -> JsonSchema(a) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
ObjectSchema(properties, required, _, meta) ->
ObjectSchema(properties, required, allow, meta)
_ -> def
}
JsonSchema(schema: new_def, decoder:, zero:)
}
/// Extract a required field from an object.
///
/// The field must be present in the JSON object, otherwise a `MissingField`
/// error is returned.
///
/// ## Example
///
/// ```gleam
/// use name <- sextant.field("name", sextant.string())
/// use age <- sextant.field("age", sextant.integer())
/// sextant.success(User(name:, age:))
/// ```
pub fn field(
name: String,
field_schema: JsonSchema(a),
next: fn(a) -> JsonSchema(b),
) -> JsonSchema(b) {
let next_schema = next(field_schema.zero)
JsonSchema(
schema: build_object_schema(name, field_schema, next_schema),
decoder: fn(data, opts) {
let field_result = get_field(data, name)
case field_result {
Ok(Some(field_data)) -> {
let #(value, field_errors) = field_schema.decoder(field_data, opts)
let field_errors = prepend_path(field_errors, name)
let next_schema = next(value)
let #(final_value, next_errors) = next_schema.decoder(data, opts)
#(final_value, list.append(field_errors, next_errors))
}
Ok(None) -> {
// Field is missing - use zero value to continue building result
let next_schema = next(field_schema.zero)
let #(final_value, next_errors) = next_schema.decoder(data, opts)
#(final_value, [MissingField(name, []), ..next_errors])
}
Error(_) -> {
// Not an object
let next_schema = next(field_schema.zero)
let #(final_value, _) = next_schema.decoder(data, opts)
#(final_value, [
TypeError("Object", dynamic.classify(data), []),
])
}
}
},
zero: next_schema.zero,
)
}
/// Extract an optional field from an object.
///
/// Returns `Some(value)` if the field is present, `None` if missing.
///
/// ## Example
///
/// ```gleam
/// use name <- sextant.field("name", sextant.string())
/// use email <- sextant.optional_field("email", sextant.string())
/// sextant.success(User(name:, email:))
/// ```
pub fn optional_field(
name: String,
field_schema: JsonSchema(a),
next: fn(Option(a)) -> JsonSchema(b),
) -> JsonSchema(b) {
let next_schema = next(None)
JsonSchema(
schema: build_object_schema_optional(name, field_schema, next_schema),
decoder: fn(data, opts) {
let field_result = get_field(data, name)
case field_result {
Ok(Some(field_data)) -> {
case is_null(field_data) {
True -> {
let next_schema = next(None)
next_schema.decoder(data, opts)
}
False -> {
let #(value, field_errors) =
field_schema.decoder(field_data, opts)
let field_errors = prepend_path(field_errors, name)
let next_schema = next(Some(value))
let #(final_value, next_errors) = next_schema.decoder(data, opts)
#(final_value, list.append(field_errors, next_errors))
}
}
}
Ok(None) -> {
// Field is missing - that's ok for optional
let next_schema = next(None)
next_schema.decoder(data, opts)
}
Error(_) -> {
// Not an object
let next_schema = next(None)
let #(final_value, _) = next_schema.decoder(data, opts)
#(final_value, [
TypeError("Object", dynamic.classify(data), []),
])
}
}
},
zero: next_schema.zero,
)
}
// ---------------------------------------------------------------------------
// Compound Types
// ---------------------------------------------------------------------------
/// Create a schema for JSON arrays.
///
/// ## Example
///
/// ```gleam
/// let tags_schema = sextant.array(of: sextant.string())
/// ```
pub fn array(of inner: JsonSchema(a)) -> JsonSchema(List(a)) {
JsonSchema(
schema: ArraySchema(inner.schema, empty_array_constraints, empty_metadata),
decoder: fn(data, opts) { decode_array(data, inner, opts) },
zero: [],
)
}
/// Create a schema for a fixed-length array with 2 elements of different types.
///
/// This generates a JSON Schema with `prefixItems` and exact length constraints.
///
/// ## Example
///
/// ```gleam
/// // A point as [x, y] coordinates
/// let point_schema = sextant.tuple2(sextant.number(), sextant.number())
///
/// // A key-value pair as [string, int]
/// let pair_schema = sextant.tuple2(sextant.string(), sextant.integer())
/// ```
pub fn tuple2(
first: JsonSchema(a),
second: JsonSchema(b),
) -> JsonSchema(#(a, b)) {
JsonSchema(
schema: TupleSchema([first.schema, second.schema], empty_metadata),
decoder: fn(data, opts) { decode_tuple2(data, first, second, opts) },
zero: #(first.zero, second.zero),
)
}
/// Create a schema for a fixed-length array with 3 elements of different types.
///
/// This generates a JSON Schema with `prefixItems` and exact length constraints.
///
/// ## Example
///
/// ```gleam
/// // RGB colour as [r, g, b]
/// let rgb_schema = sextant.tuple3(
/// sextant.integer() |> sextant.int_min(0) |> sextant.int_max(255),
/// sextant.integer() |> sextant.int_min(0) |> sextant.int_max(255),
/// sextant.integer() |> sextant.int_min(0) |> sextant.int_max(255),
/// )
/// ```
pub fn tuple3(
first: JsonSchema(a),
second: JsonSchema(b),
third: JsonSchema(c),
) -> JsonSchema(#(a, b, c)) {
JsonSchema(
schema: TupleSchema(
[first.schema, second.schema, third.schema],
empty_metadata,
),
decoder: fn(data, opts) { decode_tuple3(data, first, second, third, opts) },
zero: #(first.zero, second.zero, third.zero),
)
}
/// Create a schema for a fixed-length array with 4 elements of different types.
///
/// This generates a JSON Schema with `prefixItems` and exact length constraints.
///
/// ## Example
///
/// ```gleam
/// // RGBA colour as [r, g, b, a]
/// let rgba_schema = sextant.tuple4(
/// sextant.integer(),
/// sextant.integer(),
/// sextant.integer(),
/// sextant.number(),
/// )
/// ```
pub fn tuple4(
first: JsonSchema(a),
second: JsonSchema(b),
third: JsonSchema(c),
fourth: JsonSchema(d),
) -> JsonSchema(#(a, b, c, d)) {
JsonSchema(
schema: TupleSchema(
[first.schema, second.schema, third.schema, fourth.schema],
empty_metadata,
),
decoder: fn(data, opts) {
decode_tuple4(data, first, second, third, fourth, opts)
},
zero: #(first.zero, second.zero, third.zero, fourth.zero),
)
}
/// Create a schema for a fixed-length array with 5 elements of different types.
///
/// This generates a JSON Schema with `prefixItems` and exact length constraints.
pub fn tuple5(
first: JsonSchema(a),
second: JsonSchema(b),
third: JsonSchema(c),
fourth: JsonSchema(d),
fifth: JsonSchema(e),
) -> JsonSchema(#(a, b, c, d, e)) {
JsonSchema(
schema: TupleSchema(
[first.schema, second.schema, third.schema, fourth.schema, fifth.schema],
empty_metadata,
),
decoder: fn(data, opts) {
decode_tuple5(data, first, second, third, fourth, fifth, opts)
},
zero: #(first.zero, second.zero, third.zero, fourth.zero, fifth.zero),
)
}
/// Create a schema for a fixed-length array with 6 elements of different types.
///
/// This generates a JSON Schema with `prefixItems` and exact length constraints.
pub fn tuple6(
first: JsonSchema(a),
second: JsonSchema(b),
third: JsonSchema(c),
fourth: JsonSchema(d),
fifth: JsonSchema(e),
sixth: JsonSchema(f),
) -> JsonSchema(#(a, b, c, d, e, f)) {
JsonSchema(
schema: TupleSchema(
[
first.schema,
second.schema,
third.schema,
fourth.schema,
fifth.schema,
sixth.schema,
],
empty_metadata,
),
decoder: fn(data, opts) {
decode_tuple6(data, first, second, third, fourth, fifth, sixth, opts)
},
zero: #(
first.zero,
second.zero,
third.zero,
fourth.zero,
fifth.zero,
sixth.zero,
),
)
}
/// Create a schema that allows null values.
///
/// Returns `Some(value)` if the value is present and valid, `None` if null.
///
/// ## Example
///
/// ```gleam
/// let optional_name = sextant.optional(sextant.string())
/// ```
pub fn optional(inner: JsonSchema(a)) -> JsonSchema(Option(a)) {
JsonSchema(
schema: NullableSchema(inner.schema, empty_metadata),
decoder: fn(data, opts) {
case is_null(data) {
True -> #(None, [])
False -> {
let #(value, errors) = inner.decoder(data, opts)
#(Some(value), errors)
}
}
},
zero: None,
)
}
/// Create a schema for JSON objects with arbitrary string keys.
///
/// ## Example
///
/// ```gleam
/// let scores_schema = sextant.dict(sextant.integer())
/// // Validates: {"alice": 100, "bob": 85}
/// ```
pub fn dict(value_schema: JsonSchema(v)) -> JsonSchema(Dict(String, v)) {
JsonSchema(
schema: DictSchema(value_schema.schema, empty_metadata),
decoder: fn(data, opts) {
case
decode.run(data, decode.dict(decode.string, decode.dynamic))
|> result.replace_error(Nil)
{
Ok(d) -> {
dict.fold(d, #(dict.new(), []), fn(acc, key, value) {
let #(result_dict, errors) = acc
let #(decoded_value, value_errors) =
value_schema.decoder(value, opts)
let value_errors = prepend_path(value_errors, key)
#(
dict.insert(result_dict, key, decoded_value),
list.append(errors, value_errors),
)
})
}
Error(_) -> #(dict.new(), [
TypeError("Object", dynamic.classify(data), []),
])
}
},
zero: dict.new(),
)
}
// ---------------------------------------------------------------------------
// Combinators
// ---------------------------------------------------------------------------
/// Create a schema for a string enum that maps to Gleam values.
///
/// ## Example
///
/// ```gleam
/// type Role {
/// Admin
/// Member
/// Guest
/// }
///
/// let role_schema = sextant.enum(#("admin", Admin), [
/// #("member", Member),
/// #("guest", Guest),
/// ])
/// ```
pub fn enum(first: #(String, a), rest: List(#(String, a))) -> JsonSchema(a) {
let variants = [first, ..rest]
let enum_values = list.map(variants, fn(v) { v.0 })
let #(_, zero_val) = first
JsonSchema(
schema: EnumSchema(enum_values, empty_metadata),
decoder: fn(data, _opts) {
case decode.run(data, decode.string) |> result.replace_error(Nil) {
Ok(str) -> {
case list.find(variants, fn(v) { v.0 == str }) {
Ok(#(_, value)) -> #(value, [])
Error(_) -> #(zero_val, [
UnknownVariant(str, enum_values, []),
])
}
}
Error(_) -> #(zero_val, [
TypeError("String", dynamic.classify(data), []),
])
}
},
zero: zero_val,
)
}
/// Create a schema where exactly one of the variants must match.
///
/// Note: At runtime, `one_of` and `any_of` have identical validation behaviour -
/// the first matching schema is used. The distinction only affects the generated
/// JSON Schema output (`oneOf` vs `anyOf`).
///
/// ## Example
///
/// ```gleam
/// let string_or_int = sextant.one_of(
/// sextant.string() |> sextant.map(StringValue),
/// [sextant.integer() |> sextant.map(IntValue)],
/// )
/// ```
pub fn one_of(first: JsonSchema(a), rest: List(JsonSchema(a))) -> JsonSchema(a) {
let all_schemas = [first, ..rest]
let schema_defs = list.map(all_schemas, fn(s) { s.schema })
JsonSchema(
schema: OneOfSchema(schema_defs, empty_metadata),
decoder: fn(data, opts) {
try_decoders(data, opts, all_schemas, first, "OneOf")
},
zero: first.zero,
)
}
/// Create a schema where at least one of the variants must match.
///
/// Note: At runtime, `one_of` and `any_of` have identical validation behaviour -
/// the first matching schema is used. The distinction only affects the generated
/// JSON Schema output (`oneOf` vs `anyOf`).
///
/// ## Example
///
/// ```gleam
/// let flexible_schema = sextant.any_of(
/// sextant.string() |> sextant.map(process_string),
/// [sextant.integer() |> sextant.map(process_int)],
/// )
/// ```
pub fn any_of(first: JsonSchema(a), rest: List(JsonSchema(a))) -> JsonSchema(a) {
let all_schemas = [first, ..rest]
let schema_defs = list.map(all_schemas, fn(s) { s.schema })
JsonSchema(
schema: AnyOfSchema(schema_defs, empty_metadata),
decoder: fn(data, opts) {
try_decoders(data, opts, all_schemas, first, "AnyOf")
},
zero: first.zero,
)
}
/// Transform the decoded value using a function.
///
/// ## Example
///
/// ```gleam
/// let uppercase_string = sextant.string() |> sextant.map(string.uppercase)
/// ```
pub fn map(schema: JsonSchema(a), transform: fn(a) -> b) -> JsonSchema(b) {
JsonSchema(
schema: schema.schema,
decoder: fn(data, opts) {
let #(value, errors) = schema.decoder(data, opts)
#(transform(value), errors)
},
zero: transform(schema.zero),
)
}
/// Transform the decoded value using a fallible function.
///
/// If the transform returns `Error`, it's converted to a `ConstraintError`
/// using the provided error message. The `default` value is used as the
/// zero value for schema extraction and as the fallback when the transform fails.
///
/// Use this for validations that can't be expressed with built-in constraints,
/// such as parsing strings into custom types or cross-field validation.
///
/// ## Example
///
/// ```gleam
/// pub type Slug { Slug(String) }
///
/// fn parse_slug(s: String) -> Result(Slug, String) {
/// let is_valid = string.length(s) > 0
/// && string.lowercase(s) == s
/// && !string.contains(s, " ")
/// case is_valid {
/// True -> Ok(Slug(s))
/// False -> Error("must be lowercase with no spaces")
/// }
/// }
///
/// let slug_schema = sextant.string()
/// |> sextant.try_map(parse_slug, default: Slug("default"))
/// ```
pub fn try_map(
schema: JsonSchema(a),
transform: fn(a) -> Result(b, String),
default default: b,
) -> JsonSchema(b) {
JsonSchema(
schema: schema.schema,
decoder: fn(data, opts) {
let #(value, errors) = schema.decoder(data, opts)
case has_type_error(errors) {
True -> #(default, errors)
False ->
case transform(value) {
Ok(transformed) -> #(transformed, errors)
Error(msg) -> #(
default,
list.append(errors, [
ConstraintError(CustomViolation(msg), []),
]),
)
}
}
},
zero: default,
)
}
/// Constrain a schema to accept only a specific constant value.
///
/// The `to_json` function converts the value to its JSON representation
/// for schema generation. For primitives, use the corresponding `json` function.
///
/// ## Example
///
/// ```gleam
/// let version_schema = sextant.string() |> sextant.const_value("v1", json.string)
/// let answer_schema = sextant.integer() |> sextant.const_value(42, json.int)
/// let enabled_schema = sextant.boolean() |> sextant.const_value(True, json.bool)
/// ```
pub fn const_value(
schema: JsonSchema(a),
value: a,
to_json: fn(a) -> json.Json,
) -> JsonSchema(a) {
let JsonSchema(_def, decoder, _zero) = schema
JsonSchema(
schema: ConstSchema(to_json(value), empty_metadata),
decoder: fn(data, opts) {
let #(decoded, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(decoded, errors)
False ->
case decoded == value {
True -> #(decoded, errors)
False -> #(
value,
list.append(errors, [
ConstMismatch(
expected: string.inspect(value),
actual: string.inspect(decoded),
path: [],
),
]),
)
}
}
},
zero: value,
)
}
// ---------------------------------------------------------------------------
// String Constraints
// ---------------------------------------------------------------------------
/// Set minimum string length.
///
/// ## Example
///
/// ```gleam
/// let name_schema = sextant.string() |> sextant.min_length(1)
/// ```
pub fn min_length(schema: JsonSchema(String), min: Int) -> JsonSchema(String) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
StringSchema(constraints, meta) ->
StringSchema(
StringConstraints(..constraints, min_length: Some(min)),
meta,
)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False -> {
let len = string.length(value)
case len < min {
True -> #(
value,
list.append(errors, [
ConstraintError(
StringViolation(StringTooShort(min:, actual: len)),
[],
),
]),
)
False -> #(value, errors)
}
}
}
},
zero:,
)
}
/// Set maximum string length.
///
/// ## Example
///
/// ```gleam
/// let name_schema = sextant.string() |> sextant.max_length(100)
/// ```
pub fn max_length(schema: JsonSchema(String), max: Int) -> JsonSchema(String) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
StringSchema(constraints, meta) ->
StringSchema(
StringConstraints(..constraints, max_length: Some(max)),
meta,
)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False -> {
let len = string.length(value)
case len > max {
True -> #(
value,
list.append(errors, [
ConstraintError(
StringViolation(StringTooLong(max:, actual: len)),
[],
),
]),
)
False -> #(value, errors)
}
}
}
},
zero:,
)
}
/// Set a regex pattern the string must match.
///
/// If the pattern is not a valid regex, validation will return an
/// `InvalidPattern` constraint error.
///
/// ## Example
///
/// ```gleam
/// let slug_schema = sextant.string() |> sextant.pattern("^[a-z0-9-]+$")
/// ```
pub fn pattern(
schema: JsonSchema(String),
pattern_string: String,
) -> JsonSchema(String) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
StringSchema(constraints, meta) ->
StringSchema(
StringConstraints(..constraints, pattern: Some(pattern_string)),
meta,
)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False -> {
case regexp.from_string(pattern_string) {
Error(regexp.CompileError(error: err, byte_index: _)) -> #(
value,
list.append(errors, [
ConstraintError(
StringViolation(InvalidPattern(
pattern: pattern_string,
error: err,
)),
[],
),
]),
)
Ok(regex) ->
case regexp.check(regex, value) {
True -> #(value, errors)
False -> #(
value,
list.append(errors, [
ConstraintError(
StringViolation(PatternMismatch(
pattern: pattern_string,
actual: value,
)),
[],
),
]),
)
}
}
}
}
},
zero:,
)
}
/// Set a string format constraint.
///
/// Format validation only runs when `Options.validate_formats` is `True`.
///
/// ## Example
///
/// ```gleam
/// let email_schema = sextant.string() |> sextant.format(sextant.Email)
/// ```
pub fn format(
schema: JsonSchema(String),
fmt: StringFormat,
) -> JsonSchema(String) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
StringSchema(constraints, meta) ->
StringSchema(StringConstraints(..constraints, format: Some(fmt)), meta)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors), opts.validate_formats {
True, _ -> #(value, errors)
False, True -> {
case validate_format(value, fmt) {
True -> #(value, errors)
False -> #(
value,
list.append(errors, [
ConstraintError(
StringViolation(InvalidFormat(
format: string_format_to_string(fmt),
actual: value,
)),
[],
),
]),
)
}
}
False, False -> #(value, errors)
}
},
zero:,
)
}
// ---------------------------------------------------------------------------
// Integer Constraints
// ---------------------------------------------------------------------------
/// Set inclusive minimum value for integers.
///
/// ## Example
///
/// ```gleam
/// let age_schema = sextant.integer() |> sextant.int_min(0)
/// ```
pub fn int_min(schema: JsonSchema(Int), min: Int) -> JsonSchema(Int) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
IntegerSchema(constraints, meta) ->
IntegerSchema(IntConstraints(..constraints, minimum: Some(min)), meta)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False ->
case value < min {
True -> #(
value,
list.append(errors, [
ConstraintError(
NumberViolation(NumberTooSmall(
minimum: int.to_float(min),
exclusive: False,
actual: int.to_float(value),
)),
[],
),
]),
)
False -> #(value, errors)
}
}
},
zero:,
)
}
/// Set inclusive maximum value for integers.
///
/// ## Example
///
/// ```gleam
/// let age_schema = sextant.integer() |> sextant.int_max(150)
/// ```
pub fn int_max(schema: JsonSchema(Int), max: Int) -> JsonSchema(Int) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
IntegerSchema(constraints, meta) ->
IntegerSchema(IntConstraints(..constraints, maximum: Some(max)), meta)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False ->
case value > max {
True -> #(
value,
list.append(errors, [
ConstraintError(
NumberViolation(NumberTooLarge(
maximum: int.to_float(max),
exclusive: False,
actual: int.to_float(value),
)),
[],
),
]),
)
False -> #(value, errors)
}
}
},
zero:,
)
}
/// Set exclusive minimum value for integers.
///
/// ## Example
///
/// ```gleam
/// let positive_schema = sextant.integer() |> sextant.int_exclusive_min(0)
/// ```
pub fn int_exclusive_min(schema: JsonSchema(Int), min: Int) -> JsonSchema(Int) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
IntegerSchema(constraints, meta) ->
IntegerSchema(
IntConstraints(..constraints, exclusive_minimum: Some(min)),
meta,
)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False ->
case value <= min {
True -> #(
value,
list.append(errors, [
ConstraintError(
NumberViolation(NumberTooSmall(
minimum: int.to_float(min),
exclusive: True,
actual: int.to_float(value),
)),
[],
),
]),
)
False -> #(value, errors)
}
}
},
zero:,
)
}
/// Set exclusive maximum value for integers.
///
/// ## Example
///
/// ```gleam
/// let under_100 = sextant.integer() |> sextant.int_exclusive_max(100)
/// ```
pub fn int_exclusive_max(schema: JsonSchema(Int), max: Int) -> JsonSchema(Int) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
IntegerSchema(constraints, meta) ->
IntegerSchema(
IntConstraints(..constraints, exclusive_maximum: Some(max)),
meta,
)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False ->
case value >= max {
True -> #(
value,
list.append(errors, [
ConstraintError(
NumberViolation(NumberTooLarge(
maximum: int.to_float(max),
exclusive: True,
actual: int.to_float(value),
)),
[],
),
]),
)
False -> #(value, errors)
}
}
},
zero:,
)
}
/// Set that the integer must be a multiple of a given value.
///
/// Note: If `multiple` is 0, all values will be considered valid since
/// `x % 0 = 0` in Gleam.
///
/// ## Example
///
/// ```gleam
/// let even_schema = sextant.integer() |> sextant.int_multiple_of(2)
/// ```
pub fn int_multiple_of(
schema: JsonSchema(Int),
multiple: Int,
) -> JsonSchema(Int) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
IntegerSchema(constraints, meta) ->
IntegerSchema(
IntConstraints(..constraints, multiple_of: Some(multiple)),
meta,
)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False ->
case value % multiple == 0 {
True -> #(value, errors)
False -> #(
value,
list.append(errors, [
ConstraintError(
NumberViolation(NotMultipleOf(
multiple: int.to_float(multiple),
actual: int.to_float(value),
)),
[],
),
]),
)
}
}
},
zero:,
)
}
// ---------------------------------------------------------------------------
// Float Constraints
// ---------------------------------------------------------------------------
/// Set inclusive minimum value for floats.
///
/// ## Example
///
/// ```gleam
/// let price_schema = sextant.number() |> sextant.float_min(0.0)
/// ```
pub fn float_min(schema: JsonSchema(Float), min: Float) -> JsonSchema(Float) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
NumberSchema(constraints, meta) ->
NumberSchema(FloatConstraints(..constraints, minimum: Some(min)), meta)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False ->
case value <. min {
True -> #(
value,
list.append(errors, [
ConstraintError(
NumberViolation(NumberTooSmall(
minimum: min,
exclusive: False,
actual: value,
)),
[],
),
]),
)
False -> #(value, errors)
}
}
},
zero:,
)
}
/// Set inclusive maximum value for floats.
///
/// ## Example
///
/// ```gleam
/// let percentage_schema = sextant.number() |> sextant.float_max(100.0)
/// ```
pub fn float_max(schema: JsonSchema(Float), max: Float) -> JsonSchema(Float) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
NumberSchema(constraints, meta) ->
NumberSchema(FloatConstraints(..constraints, maximum: Some(max)), meta)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False ->
case value >. max {
True -> #(
value,
list.append(errors, [
ConstraintError(
NumberViolation(NumberTooLarge(
maximum: max,
exclusive: False,
actual: value,
)),
[],
),
]),
)
False -> #(value, errors)
}
}
},
zero:,
)
}
/// Set exclusive minimum value for floats.
///
/// ## Example
///
/// ```gleam
/// let positive_schema = sextant.number() |> sextant.float_exclusive_min(0.0)
/// ```
pub fn float_exclusive_min(
schema: JsonSchema(Float),
min: Float,
) -> JsonSchema(Float) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
NumberSchema(constraints, meta) ->
NumberSchema(
FloatConstraints(..constraints, exclusive_minimum: Some(min)),
meta,
)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False ->
case value <=. min {
True -> #(
value,
list.append(errors, [
ConstraintError(
NumberViolation(NumberTooSmall(
minimum: min,
exclusive: True,
actual: value,
)),
[],
),
]),
)
False -> #(value, errors)
}
}
},
zero:,
)
}
/// Set exclusive maximum value for floats.
///
/// ## Example
///
/// ```gleam
/// let under_100 = sextant.number() |> sextant.float_exclusive_max(100.0)
/// ```
pub fn float_exclusive_max(
schema: JsonSchema(Float),
max: Float,
) -> JsonSchema(Float) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
NumberSchema(constraints, meta) ->
NumberSchema(
FloatConstraints(..constraints, exclusive_maximum: Some(max)),
meta,
)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False ->
case value >=. max {
True -> #(
value,
list.append(errors, [
ConstraintError(
NumberViolation(NumberTooLarge(
maximum: max,
exclusive: True,
actual: value,
)),
[],
),
]),
)
False -> #(value, errors)
}
}
},
zero:,
)
}
/// Set that the float must be a multiple of a given value.
///
/// Note: Due to floating-point precision, a small tolerance (1e-7) is used
/// when checking the remainder. If `multiple` is 0.0, all values will be
/// considered valid since `x %.. 0.0 = 0.0` in Gleam.
///
/// ## Example
///
/// ```gleam
/// let quarter_schema = sextant.number() |> sextant.float_multiple_of(0.25)
/// ```
pub fn float_multiple_of(
schema: JsonSchema(Float),
multiple: Float,
) -> JsonSchema(Float) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
NumberSchema(constraints, meta) ->
NumberSchema(
FloatConstraints(..constraints, multiple_of: Some(multiple)),
meta,
)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False -> {
let remainder = float_modulo(value, multiple)
case
remainder == 0.0 || float.absolute_value(remainder) <. 0.0000001
{
True -> #(value, errors)
False -> #(
value,
list.append(errors, [
ConstraintError(
NumberViolation(NotMultipleOf(multiple:, actual: value)),
[],
),
]),
)
}
}
}
},
zero:,
)
}
// ---------------------------------------------------------------------------
// Array Constraints
// ---------------------------------------------------------------------------
/// Set minimum number of items in an array.
///
/// ## Example
///
/// ```gleam
/// let tags_schema = sextant.array(sextant.string()) |> sextant.min_items(1)
/// ```
pub fn min_items(schema: JsonSchema(List(a)), min: Int) -> JsonSchema(List(a)) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
ArraySchema(items, constraints, meta) ->
ArraySchema(
items,
ArrayConstraints(..constraints, min_items: Some(min)),
meta,
)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False -> {
let len = list.length(value)
case len < min {
True -> #(
value,
list.append(errors, [
ConstraintError(
ArrayViolation(ArrayTooShort(min:, actual: len)),
[],
),
]),
)
False -> #(value, errors)
}
}
}
},
zero:,
)
}
/// Set maximum number of items in an array.
///
/// ## Example
///
/// ```gleam
/// let tags_schema = sextant.array(sextant.string()) |> sextant.max_items(10)
/// ```
pub fn max_items(schema: JsonSchema(List(a)), max: Int) -> JsonSchema(List(a)) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
ArraySchema(items, constraints, meta) ->
ArraySchema(
items,
ArrayConstraints(..constraints, max_items: Some(max)),
meta,
)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False -> {
let len = list.length(value)
case len > max {
True -> #(
value,
list.append(errors, [
ConstraintError(
ArrayViolation(ArrayTooLong(max:, actual: len)),
[],
),
]),
)
False -> #(value, errors)
}
}
}
},
zero:,
)
}
/// Require all array items to be unique.
///
/// ## Example
///
/// ```gleam
/// let unique_tags = sextant.array(sextant.string()) |> sextant.unique_items()
/// ```
pub fn unique_items(schema: JsonSchema(List(a))) -> JsonSchema(List(a)) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
ArraySchema(items, constraints, meta) ->
ArraySchema(
items,
ArrayConstraints(..constraints, unique_items: True),
meta,
)
_ -> def
}
JsonSchema(
schema: new_def,
decoder: fn(data, opts) {
let #(value, errors) = decoder(data, opts)
case has_type_error(errors) {
True -> #(value, errors)
False -> {
let unique_count = list.unique(value) |> list.length
let total_count = list.length(value)
case unique_count == total_count {
True -> #(value, errors)
False -> #(
value,
list.append(errors, [
ConstraintError(ArrayViolation(ItemsNotUnique), []),
]),
)
}
}
}
},
zero:,
)
}
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
/// Add a description to the schema.
///
/// ## Example
///
/// ```gleam
/// let name_schema = sextant.string() |> sextant.describe("The user's name")
/// ```
pub fn describe(schema: JsonSchema(a), description: String) -> JsonSchema(a) {
update_metadata(schema, fn(m) {
Metadata(..m, description: Some(description))
})
}
/// Add a title to the schema.
///
/// ## Example
///
/// ```gleam
/// let name_schema = sextant.string() |> sextant.title("User Name")
/// ```
pub fn title(schema: JsonSchema(a), title_text: String) -> JsonSchema(a) {
update_metadata(schema, fn(m) { Metadata(..m, title: Some(title_text)) })
}
/// Add examples to the schema.
///
/// ## Example
///
/// ```gleam
/// let name_schema = sextant.string()
/// |> sextant.examples([json.string("Alice"), json.string("Bob")])
/// ```
pub fn examples(schema: JsonSchema(a), ex: List(json.Json)) -> JsonSchema(a) {
update_metadata(schema, fn(m) { Metadata(..m, examples: ex) })
}
/// Add a default value to the schema.
///
/// ## Example
///
/// ```gleam
/// let count_schema = sextant.integer()
/// |> sextant.default(json.int(0))
/// ```
pub fn default(schema: JsonSchema(a), def: json.Json) -> JsonSchema(a) {
update_metadata(schema, fn(m) { Metadata(..m, default: Some(def)) })
}
// ---------------------------------------------------------------------------
// Execution
// ---------------------------------------------------------------------------
/// Validate and decode dynamic data against a schema.
///
/// ## Example
///
/// ```gleam
/// case sextant.run(data, user_schema()) {
/// Ok(user) -> // use the decoded user
/// Error(errors) -> // handle validation errors
/// }
/// ```
pub fn run(
data: Dynamic,
schema: JsonSchema(a),
) -> Result(a, List(ValidationError)) {
run_with_options(data, schema, default_options)
}
/// Validate and decode dynamic data with custom options.
///
/// ## Example
///
/// ```gleam
/// let opts = sextant.Options(validate_formats: True)
/// case sextant.run_with_options(data, email_schema, opts) {
/// Ok(email) -> // email format was validated
/// Error(errors) -> // handle validation errors
/// }
/// ```
pub fn run_with_options(
data: Dynamic,
schema: JsonSchema(a),
options: Options,
) -> Result(a, List(ValidationError)) {
let #(value, errors) = schema.decoder(data, options)
case errors {
[] -> Ok(value)
_ -> Error(errors)
}
}
/// Generate a JSON Schema 2020-12 document.
///
/// ## Example
///
/// ```gleam
/// let schema_json = sextant.to_json(user_schema())
/// ```
pub fn to_json(schema: JsonSchema(a)) -> json.Json {
let base_fields = schema_definition_to_fields(schema.schema)
json.object([
#("$schema", json.string("https://json-schema.org/draft/2020-12/schema")),
..base_fields
])
}
// ---------------------------------------------------------------------------
// Internal: JSON Schema Encoding
// ---------------------------------------------------------------------------
fn schema_definition_to_json(def: SchemaDefinition) -> json.Json {
json.object(schema_definition_to_fields(def))
}
fn schema_definition_to_fields(
def: SchemaDefinition,
) -> List(#(String, json.Json)) {
case def {
StringSchema(constraints, meta) -> {
let base = [#("type", json.string("string"))]
let with_constraints = add_string_constraints(base, constraints)
add_metadata(with_constraints, meta)
}
IntegerSchema(constraints, meta) -> {
let base = [#("type", json.string("integer"))]
let with_constraints = add_int_constraints(base, constraints)
add_metadata(with_constraints, meta)
}
NumberSchema(constraints, meta) -> {
let base = [#("type", json.string("number"))]
let with_constraints = add_float_constraints(base, constraints)
add_metadata(with_constraints, meta)
}
BooleanSchema(meta) -> {
let base = [#("type", json.string("boolean"))]
add_metadata(base, meta)
}
NullSchema(meta) -> {
let base = [#("type", json.string("null"))]
add_metadata(base, meta)
}
ArraySchema(items, constraints, meta) -> {
let base = [
#("type", json.string("array")),
#("items", schema_definition_to_json(items)),
]
let with_constraints = add_array_constraints(base, constraints)
add_metadata(with_constraints, meta)
}
ObjectSchema(properties, required, additional_properties, meta) -> {
let props =
properties
|> list.map(fn(p) { #(p.name, schema_definition_to_json(p.schema)) })
|> json.object
let base = case additional_properties {
True -> [#("type", json.string("object")), #("properties", props)]
False -> [
#("type", json.string("object")),
#("properties", props),
#("additionalProperties", json.bool(False)),
]
}
let with_required = case required {
[] -> base
_ -> [#("required", json.array(required, json.string)), ..base]
}
add_metadata(with_required, meta)
}
DictSchema(values, meta) -> {
let base = [
#("type", json.string("object")),
#("additionalProperties", schema_definition_to_json(values)),
]
add_metadata(base, meta)
}
NullableSchema(inner, meta) -> {
let base = [
#(
"oneOf",
json.array(
[NullSchema(empty_metadata), inner],
schema_definition_to_json,
),
),
]
add_metadata(base, meta)
}
OneOfSchema(variants, meta) -> {
let base = [
#("oneOf", json.array(variants, schema_definition_to_json)),
]
add_metadata(base, meta)
}
AnyOfSchema(variants, meta) -> {
let base = [
#("anyOf", json.array(variants, schema_definition_to_json)),
]
add_metadata(base, meta)
}
EnumSchema(values, meta) -> {
let base = [
#("type", json.string("string")),
#("enum", json.array(values, json.string)),
]
add_metadata(base, meta)
}
ConstSchema(value, meta) -> {
let base = [#("const", value)]
add_metadata(base, meta)
}
TupleSchema(items, meta) -> {
let base = [
#("type", json.string("array")),
#("prefixItems", json.array(items, schema_definition_to_json)),
#("items", json.bool(False)),
#("minItems", json.int(list.length(items))),
#("maxItems", json.int(list.length(items))),
]
add_metadata(base, meta)
}
}
}
fn add_string_constraints(
fields: List(#(String, json.Json)),
constraints: StringConstraints,
) -> List(#(String, json.Json)) {
fields
|> add_optional_int("minLength", constraints.min_length)
|> add_optional_int("maxLength", constraints.max_length)
|> add_optional_string("pattern", constraints.pattern)
|> add_optional_format(constraints.format)
}
fn add_int_constraints(
fields: List(#(String, json.Json)),
constraints: IntConstraints,
) -> List(#(String, json.Json)) {
fields
|> add_optional_int("minimum", constraints.minimum)
|> add_optional_int("maximum", constraints.maximum)
|> add_optional_int("exclusiveMinimum", constraints.exclusive_minimum)
|> add_optional_int("exclusiveMaximum", constraints.exclusive_maximum)
|> add_optional_int("multipleOf", constraints.multiple_of)
}
fn add_float_constraints(
fields: List(#(String, json.Json)),
constraints: FloatConstraints,
) -> List(#(String, json.Json)) {
fields
|> add_optional_float("minimum", constraints.minimum)
|> add_optional_float("maximum", constraints.maximum)
|> add_optional_float("exclusiveMinimum", constraints.exclusive_minimum)
|> add_optional_float("exclusiveMaximum", constraints.exclusive_maximum)
|> add_optional_float("multipleOf", constraints.multiple_of)
}
fn add_array_constraints(
fields: List(#(String, json.Json)),
constraints: ArrayConstraints,
) -> List(#(String, json.Json)) {
let fields =
fields
|> add_optional_int("minItems", constraints.min_items)
|> add_optional_int("maxItems", constraints.max_items)
case constraints.unique_items {
True -> [#("uniqueItems", json.bool(True)), ..fields]
False -> fields
}
}
fn add_metadata(
fields: List(#(String, json.Json)),
meta: Metadata,
) -> List(#(String, json.Json)) {
fields
|> add_optional_string("title", meta.title)
|> add_optional_string("description", meta.description)
|> add_optional_json("default", meta.default)
|> add_examples(meta.examples)
}
fn add_examples(
fields: List(#(String, json.Json)),
examples: List(json.Json),
) -> List(#(String, json.Json)) {
case examples {
[] -> fields
_ -> [#("examples", json.array(examples, fn(x) { x })), ..fields]
}
}
fn add_optional_int(
fields: List(#(String, json.Json)),
name: String,
value: Option(Int),
) -> List(#(String, json.Json)) {
case value {
Some(v) -> [#(name, json.int(v)), ..fields]
None -> fields
}
}
fn add_optional_float(
fields: List(#(String, json.Json)),
name: String,
value: Option(Float),
) -> List(#(String, json.Json)) {
case value {
Some(v) -> [#(name, json.float(v)), ..fields]
None -> fields
}
}
fn add_optional_string(
fields: List(#(String, json.Json)),
name: String,
value: Option(String),
) -> List(#(String, json.Json)) {
case value {
Some(v) -> [#(name, json.string(v)), ..fields]
None -> fields
}
}
fn add_optional_json(
fields: List(#(String, json.Json)),
name: String,
value: Option(json.Json),
) -> List(#(String, json.Json)) {
case value {
Some(v) -> [#(name, v), ..fields]
None -> fields
}
}
fn add_optional_format(
fields: List(#(String, json.Json)),
fmt: Option(StringFormat),
) -> List(#(String, json.Json)) {
case fmt {
Some(f) -> [#("format", json.string(string_format_to_string(f))), ..fields]
None -> fields
}
}
// ---------------------------------------------------------------------------
// Internal: Decoding Helpers
// ---------------------------------------------------------------------------
fn decode_array(
data: Dynamic,
inner: JsonSchema(a),
opts: Options,
) -> #(List(a), List(ValidationError)) {
case
decode.run(data, decode.list(decode.dynamic))
|> result.replace_error(Nil)
{
Ok(items) -> {
let #(values, errors) =
list.index_fold(items, #([], []), fn(acc, item, index) {
let #(values, errors) = acc
let #(value, item_errors) = inner.decoder(item, opts)
let item_errors = prepend_path(item_errors, int.to_string(index))
#([value, ..values], list.append(errors, item_errors))
})
#(list.reverse(values), errors)
}
Error(_) -> #([], [
TypeError("Array", dynamic.classify(data), []),
])
}
}
fn decode_tuple2(
data: Dynamic,
first: JsonSchema(a),
second: JsonSchema(b),
opts: Options,
) -> #(#(a, b), List(ValidationError)) {
case
decode.run(data, decode.list(decode.dynamic)) |> result.replace_error(Nil)
{
Ok(items) ->
case items {
[item0, item1] -> {
let #(v0, e0) = first.decoder(item0, opts)
let e0 = prepend_path(e0, "0")
let #(v1, e1) = second.decoder(item1, opts)
let e1 = prepend_path(e1, "1")
#(#(v0, v1), list.append(e0, e1))
}
_ -> #(#(first.zero, second.zero), [
TypeError(
"Tuple[2]",
"Array[" <> int.to_string(list.length(items)) <> "]",
[],
),
])
}
Error(_) -> #(#(first.zero, second.zero), [
TypeError("Tuple[2]", dynamic.classify(data), []),
])
}
}
fn decode_tuple3(
data: Dynamic,
first: JsonSchema(a),
second: JsonSchema(b),
third: JsonSchema(c),
opts: Options,
) -> #(#(a, b, c), List(ValidationError)) {
case
decode.run(data, decode.list(decode.dynamic)) |> result.replace_error(Nil)
{
Ok(items) ->
case items {
[item0, item1, item2] -> {
let #(v0, e0) = first.decoder(item0, opts)
let e0 = prepend_path(e0, "0")
let #(v1, e1) = second.decoder(item1, opts)
let e1 = prepend_path(e1, "1")
let #(v2, e2) = third.decoder(item2, opts)
let e2 = prepend_path(e2, "2")
#(#(v0, v1, v2), list.flatten([e0, e1, e2]))
}
_ -> #(#(first.zero, second.zero, third.zero), [
TypeError(
"Tuple[3]",
"Array[" <> int.to_string(list.length(items)) <> "]",
[],
),
])
}
Error(_) -> #(#(first.zero, second.zero, third.zero), [
TypeError("Tuple[3]", dynamic.classify(data), []),
])
}
}
fn decode_tuple4(
data: Dynamic,
first: JsonSchema(a),
second: JsonSchema(b),
third: JsonSchema(c),
fourth: JsonSchema(d),
opts: Options,
) -> #(#(a, b, c, d), List(ValidationError)) {
case
decode.run(data, decode.list(decode.dynamic)) |> result.replace_error(Nil)
{
Ok(items) ->
case items {
[item0, item1, item2, item3] -> {
let #(v0, e0) = first.decoder(item0, opts)
let e0 = prepend_path(e0, "0")
let #(v1, e1) = second.decoder(item1, opts)
let e1 = prepend_path(e1, "1")
let #(v2, e2) = third.decoder(item2, opts)
let e2 = prepend_path(e2, "2")
let #(v3, e3) = fourth.decoder(item3, opts)
let e3 = prepend_path(e3, "3")
#(#(v0, v1, v2, v3), list.flatten([e0, e1, e2, e3]))
}
_ -> #(#(first.zero, second.zero, third.zero, fourth.zero), [
TypeError(
"Tuple[4]",
"Array[" <> int.to_string(list.length(items)) <> "]",
[],
),
])
}
Error(_) -> #(#(first.zero, second.zero, third.zero, fourth.zero), [
TypeError("Tuple[4]", dynamic.classify(data), []),
])
}
}
fn decode_tuple5(
data: Dynamic,
first: JsonSchema(a),
second: JsonSchema(b),
third: JsonSchema(c),
fourth: JsonSchema(d),
fifth: JsonSchema(e),
opts: Options,
) -> #(#(a, b, c, d, e), List(ValidationError)) {
case
decode.run(data, decode.list(decode.dynamic)) |> result.replace_error(Nil)
{
Ok(items) ->
case items {
[item0, item1, item2, item3, item4] -> {
let #(v0, e0) = first.decoder(item0, opts)
let e0 = prepend_path(e0, "0")
let #(v1, e1) = second.decoder(item1, opts)
let e1 = prepend_path(e1, "1")
let #(v2, e2) = third.decoder(item2, opts)
let e2 = prepend_path(e2, "2")
let #(v3, e3) = fourth.decoder(item3, opts)
let e3 = prepend_path(e3, "3")
let #(v4, e4) = fifth.decoder(item4, opts)
let e4 = prepend_path(e4, "4")
#(#(v0, v1, v2, v3, v4), list.flatten([e0, e1, e2, e3, e4]))
}
_ -> #(#(first.zero, second.zero, third.zero, fourth.zero, fifth.zero), [
TypeError(
"Tuple[5]",
"Array[" <> int.to_string(list.length(items)) <> "]",
[],
),
])
}
Error(_) -> #(
#(first.zero, second.zero, third.zero, fourth.zero, fifth.zero),
[
TypeError("Tuple[5]", dynamic.classify(data), []),
],
)
}
}
fn decode_tuple6(
data: Dynamic,
first: JsonSchema(a),
second: JsonSchema(b),
third: JsonSchema(c),
fourth: JsonSchema(d),
fifth: JsonSchema(e),
sixth: JsonSchema(f),
opts: Options,
) -> #(#(a, b, c, d, e, f), List(ValidationError)) {
case
decode.run(data, decode.list(decode.dynamic)) |> result.replace_error(Nil)
{
Ok(items) ->
case items {
[item0, item1, item2, item3, item4, item5] -> {
let #(v0, e0) = first.decoder(item0, opts)
let e0 = prepend_path(e0, "0")
let #(v1, e1) = second.decoder(item1, opts)
let e1 = prepend_path(e1, "1")
let #(v2, e2) = third.decoder(item2, opts)
let e2 = prepend_path(e2, "2")
let #(v3, e3) = fourth.decoder(item3, opts)
let e3 = prepend_path(e3, "3")
let #(v4, e4) = fifth.decoder(item4, opts)
let e4 = prepend_path(e4, "4")
let #(v5, e5) = sixth.decoder(item5, opts)
let e5 = prepend_path(e5, "5")
#(#(v0, v1, v2, v3, v4, v5), list.flatten([e0, e1, e2, e3, e4, e5]))
}
_ -> #(
#(
first.zero,
second.zero,
third.zero,
fourth.zero,
fifth.zero,
sixth.zero,
),
[
TypeError(
"Tuple[6]",
"Array[" <> int.to_string(list.length(items)) <> "]",
[],
),
],
)
}
Error(_) -> #(
#(
first.zero,
second.zero,
third.zero,
fourth.zero,
fifth.zero,
sixth.zero,
),
[
TypeError("Tuple[6]", dynamic.classify(data), []),
],
)
}
}
fn try_decoders(
data: Dynamic,
opts: Options,
schemas: List(JsonSchema(a)),
fallback: JsonSchema(a),
type_name: String,
) -> #(a, List(ValidationError)) {
case schemas {
[] -> {
let #(value, _) = fallback.decoder(data, opts)
#(value, [TypeError(type_name, dynamic.classify(data), [])])
}
[sch, ..rest] -> {
let #(value, errors) = sch.decoder(data, opts)
case errors {
[] -> #(value, [])
_ -> try_decoders(data, opts, rest, fallback, type_name)
}
}
}
}
fn prepend_path(
errors: List(ValidationError),
segment: String,
) -> List(ValidationError) {
list.map(errors, fn(error) {
case error {
TypeError(expected, found, path) ->
TypeError(expected, found, [segment, ..path])
ConstraintError(violation, path) ->
ConstraintError(violation, [segment, ..path])
MissingField(fld, path) -> MissingField(fld, [segment, ..path])
UnknownVariant(value, expected, path) ->
UnknownVariant(value, expected, [segment, ..path])
ConstMismatch(expected, actual, path) ->
ConstMismatch(expected, actual, [segment, ..path])
}
})
}
fn build_object_schema(
name: String,
field_schema: JsonSchema(a),
next_schema: JsonSchema(final),
) -> SchemaDefinition {
case next_schema.schema {
ObjectSchema(properties, required, additional, meta) -> {
let new_properties = [Property(name, field_schema.schema), ..properties]
let new_required = [name, ..required]
ObjectSchema(new_properties, new_required, additional, meta)
}
_ -> next_schema.schema
}
}
fn build_object_schema_optional(
name: String,
field_schema: JsonSchema(a),
next_schema: JsonSchema(final),
) -> SchemaDefinition {
case next_schema.schema {
ObjectSchema(properties, required, additional, meta) -> {
let new_properties = [Property(name, field_schema.schema), ..properties]
ObjectSchema(new_properties, required, additional, meta)
}
_ -> next_schema.schema
}
}
fn get_field(data: Dynamic, name: String) -> Result(Option(Dynamic), Nil) {
case
decode.run(data, decode.dict(decode.string, decode.dynamic))
|> result.replace_error(Nil)
{
Ok(d) -> Ok(dict.get(d, name) |> option.from_result)
Error(_) -> Error(Nil)
}
}
fn update_metadata(
schema: JsonSchema(a),
update: fn(Metadata) -> Metadata,
) -> JsonSchema(a) {
let JsonSchema(def, decoder, zero) = schema
let new_def = case def {
StringSchema(constraints, meta) -> StringSchema(constraints, update(meta))
IntegerSchema(constraints, meta) -> IntegerSchema(constraints, update(meta))
NumberSchema(constraints, meta) -> NumberSchema(constraints, update(meta))
BooleanSchema(meta) -> BooleanSchema(update(meta))
NullSchema(meta) -> NullSchema(update(meta))
ArraySchema(items, constraints, meta) ->
ArraySchema(items, constraints, update(meta))
ObjectSchema(properties, required, additional, meta) ->
ObjectSchema(properties, required, additional, update(meta))
DictSchema(value_schema, meta) -> DictSchema(value_schema, update(meta))
NullableSchema(inner, meta) -> NullableSchema(inner, update(meta))
OneOfSchema(variants, meta) -> OneOfSchema(variants, update(meta))
AnyOfSchema(variants, meta) -> AnyOfSchema(variants, update(meta))
EnumSchema(values, meta) -> EnumSchema(values, update(meta))
ConstSchema(value, meta) -> ConstSchema(value, update(meta))
TupleSchema(items, meta) -> TupleSchema(items, update(meta))
}
JsonSchema(schema: new_def, decoder:, zero:)
}
/// Validate IPv6 address format
/// Handles full, compressed (::), and mixed IPv4 formats
fn validate_ipv6(value: String) -> Bool {
case value {
"" -> False
_ -> {
// Check for IPv4-mapped addresses (::ffff:192.168.1.1)
case string.contains(value, ".") {
True -> validate_ipv6_mixed(value)
False -> validate_ipv6_pure(value)
}
}
}
}
fn validate_ipv6_pure(value: String) -> Bool {
let parts = string.split(value, ":")
let part_count = list.length(parts)
let has_compression = string.contains(value, "::")
case has_compression {
True -> {
// Can't have more than one ::
case count_substring(value, "::") > 1 {
True -> False
False -> {
let non_empty = list.filter(parts, fn(p) { p != "" })
// Must have at most 7 non-empty parts with compression
list.length(non_empty) <= 7
&& list.all(non_empty, is_valid_ipv6_segment)
}
}
}
False -> {
// Without compression, must have exactly 8 parts
part_count == 8 && list.all(parts, is_valid_ipv6_segment)
}
}
}
fn validate_ipv6_mixed(value: String) -> Bool {
let parts = string.split(value, ":")
let part_count = list.length(parts)
case part_count >= 2 {
False -> False
True -> {
let assert Ok(ipv4_part) = list.last(parts)
let ipv6_parts = list.take(parts, part_count - 1)
case validate_ipv4_format(ipv4_part) {
False -> False
True -> {
let has_compression = string.contains(value, "::")
let non_empty_ipv6 = list.filter(ipv6_parts, fn(p) { p != "" })
case has_compression {
True -> {
case count_substring(value, "::") > 1 {
True -> False
False ->
list.length(non_empty_ipv6) <= 5
&& list.all(non_empty_ipv6, is_valid_ipv6_segment)
}
}
False ->
list.length(ipv6_parts) == 6
&& list.all(ipv6_parts, is_valid_ipv6_segment)
}
}
}
}
}
}
fn is_valid_ipv6_segment(segment: String) -> Bool {
let len = string.length(segment)
case len >= 1 && len <= 4 {
False -> False
True -> {
segment
|> string.lowercase
|> string.to_graphemes
|> list.all(fn(c) {
case c {
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True
"a" | "b" | "c" | "d" | "e" | "f" -> True
_ -> False
}
})
}
}
}
fn validate_ipv4_format(value: String) -> Bool {
let parts = string.split(value, ".")
case list.length(parts) == 4 {
False -> False
True ->
list.all(parts, fn(p) {
case int.parse(p) {
Ok(n) -> n >= 0 && n <= 255
Error(_) -> False
}
})
}
}
fn count_substring(haystack: String, needle: String) -> Int {
count_substring_loop(haystack, needle, 0)
}
fn count_substring_loop(haystack: String, needle: String, count: Int) -> Int {
case string.split_once(haystack, needle) {
Error(_) -> count
Ok(#(_, rest)) -> count_substring_loop(rest, needle, count + 1)
}
}
fn validate_format(value: String, fmt: StringFormat) -> Bool {
case fmt {
Email -> {
let assert Ok(re) = regexp.from_string("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
regexp.check(re, value)
}
Uri -> validate_uri(value)
DateTime -> {
case timestamp.parse_rfc3339(value) {
Ok(_) -> True
Error(_) -> False
}
}
Date -> validate_date_format(value)
Time -> validate_time_format(value)
Uuid -> {
case uuid.from_string(value) {
Ok(_) -> True
Error(_) -> False
}
}
Hostname -> {
let assert Ok(re) =
regexp.from_string(
"^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$",
)
regexp.check(re, value)
}
Ipv4 -> {
let assert Ok(re) =
regexp.from_string(
"^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$",
)
regexp.check(re, value)
}
Ipv6 -> validate_ipv6(value)
}
}
/// Validate date format (YYYY-MM-DD) with actual date validation
fn validate_date_format(value: String) -> Bool {
let assert Ok(re) = regexp.from_string("^(\\d{4})-(\\d{2})-(\\d{2})$")
case regexp.scan(re, value) {
[regexp.Match(_, [Some(year_str), Some(month_str), Some(day_str)])] -> {
case int.parse(year_str), int.parse(month_str), int.parse(day_str) {
Ok(year), Ok(month), Ok(day) -> is_valid_date(year, month, day)
_, _, _ -> False
}
}
_ -> False
}
}
/// Check if a date is valid
fn is_valid_date(year: Int, month: Int, day: Int) -> Bool {
case month {
1 | 3 | 5 | 7 | 8 | 10 | 12 -> day >= 1 && day <= 31
4 | 6 | 9 | 11 -> day >= 1 && day <= 30
2 -> {
let is_leap = is_leap_year(year)
let max_day = case is_leap {
True -> 29
False -> 28
}
day >= 1 && day <= max_day
}
_ -> False
}
}
/// Check if a year is a leap year
fn is_leap_year(year: Int) -> Bool {
{ year % 4 == 0 && year % 100 != 0 } || year % 400 == 0
}
/// Validate time format (HH:MM:SS or HH:MM:SS.sss) with actual time validation
fn validate_time_format(value: String) -> Bool {
let assert Ok(re) =
regexp.from_string("^(\\d{2}):(\\d{2}):(\\d{2})(\\.\\d+)?$")
case regexp.scan(re, value) {
[regexp.Match(_, [Some(hour_str), Some(minute_str), Some(second_str), ..])] -> {
case int.parse(hour_str), int.parse(minute_str), int.parse(second_str) {
Ok(hour), Ok(minute), Ok(second) ->
hour >= 0
&& hour <= 23
&& minute >= 0
&& minute <= 59
&& second >= 0
&& second <= 60
// 60 for leap seconds
_, _, _ -> False
}
}
_ -> False
}
}
fn float_modulo(a: Float, b: Float) -> Float {
a -. { int.to_float(float.truncate(a /. b)) *. b }
}
/// Validate URI format - requires valid URI structure with a scheme.
/// Used by both uri() schema and format(Uri) validator for consistency.
fn validate_uri(value: String) -> Bool {
case uri.parse(value) {
Ok(parsed) ->
// Must have a scheme to be a valid absolute URI per JSON Schema
case parsed.scheme {
Some(_) -> True
None -> False
}
Error(_) -> False
}
}
/// Check if dynamic data is null/nil/undefined
fn is_null(data: Dynamic) -> Bool {
case decode.run(data, decode.optional(decode.dynamic)) {
Ok(None) -> True
_ -> False
}
}
/// Convert StringFormat to JSON Schema format string
fn string_format_to_string(fmt: StringFormat) -> String {
case fmt {
Email -> "email"
Uri -> "uri"
DateTime -> "date-time"
Date -> "date"
Time -> "time"
Uuid -> "uuid"
Hostname -> "hostname"
Ipv4 -> "ipv4"
Ipv6 -> "ipv6"
}
}