maru

A minimal MoonBit validation library that constructs a typed value while validating, without holding a schema as a value.

json
validation
Download zip
Author
Version
0.3.0
License
MIT
Last updated
11 hours ago
Downloads
6

#maru

A minimal MoonBit validation library that constructs a typed value while validating, without holding a schema as a value.

///|
test {
let json : Json = { "name": "Ada", "age": 36 }
let obj = @maru.from_json(json).object()
let name = obj.field("name").string().nonempty().value()
let age = obj.field("age").int().range(0, 150).value()
assert_eq(name, "Ada")
assert_eq(age, 36)
}

#Install

moon add kosei28/maru

Then import it in moon.pkg:

import { "kosei28/maru" }

#Design

  • No schema-as-value
  • No validator builder or delayed evaluation
  • Keep fetching, type checks, and value checks separate
  • Propagate errors with MoonBit raise
  • maru types are thin wrappers that only hold a value and a path
  • Start with from_json to wrap JSON as a Value
  • Finish with .value() to unwrap to a plain MoonBit value
  • Parse unsupported types yourself via Value::raw()

#Model

Raw Json, object fields, and array elements are all the same Value.

///|
test {
let value = @maru.from_json({ "ok": true })
assert_eq(value.object().field("ok").bool().value(), true)
}

Type conversion:

value.null() value.bool() value.int() value.int64() value.double() value.string() value.array() value.object()

On failure, maru raises @maru.Invalid. The message includes the path where it failed.

users[2].email: invalid email

#Value checks

Typed[String] .nonempty() .min_len(n) .max_len(n) .len(n) .starts_with(s) .ends_with(s) .includes(s) .lowercase() .uppercase() .regex(pattern) .email() .url() .uuid() .ipv4() .ipv6() .date() .time() .datetime() .base64() .eq(value) .refine(predicate, message) Typed[Int] .min(n) .max(n) .range(min, max) .gt(n) .lt(n) .positive() .negative() .nonnegative() .nonpositive() .multiple_of(n) .eq(value) .refine(predicate, message) Typed[Int64] .min(n) .max(n) .range(min, max) .gt(n) .lt(n) .positive() .negative() .nonnegative() .nonpositive() .multiple_of(n) .eq(value) .refine(predicate, message) Typed[Double] .min(n) .max(n) .range(min, max) .gt(n) .lt(n) .positive() .negative() .nonnegative() .nonpositive() .multiple_of(n) .eq(value) .refine(predicate, message) Typed[Bool] .eq(value) .refine(predicate, message) Typed[Array[T]] .nonempty() .min_len(n) .max_len(n) .len(n) .unique() .map(f) .eq(value) .refine(predicate, message)

#Building a struct

///|
fn User::parse(value : @maru.Value) -> User raise @maru.Invalid {
let obj = value.object()
User::{
name: obj.field("name").string().nonempty().value(),
email: obj.field("email").string().email().value(),
age: obj.field("age").int().range(0, 150).value(),
rating: obj.field("rating").double().range(0.0, 5.0).value(),
nickname: obj
.optional_field("nickname")
.map(v => v.string().max_len(30).value()),
}
}

From the top level, call User::parse(@maru.from_json(json)).

#Objects

Fetching a field and checking its type are separate.

Object ↓ field("name") Value ↓ string() Typed[String]

  • field is required. A missing key is an error
  • optional_field returns None only when the key is absent

///|
test {
let obj = @maru.from_json({ "name": "Ada" }).object()
let nickname = obj
.optional_field("nickname")
.map(v => v.string().max_len(30).value())
assert_eq(nickname, None)
}

#Arrays

Array elements are Values as well.

///|
test {
let scores = @maru.from_json({ "scores": [10.0, 99.5] })
.object()
.field("scores")
.array()
.map(v => v.double().range(0.0, 100.0).value())
.value()
assert_eq(scores, [10.0, 99.5])
}

#Multiple candidates

first_of applies parsers (Parser[T]) to the same Value in order and returns the first success.

///|
test {
let id = @maru.first_of(@maru.from_json(7), [
v => v.string().value(),
v => v.int().value().to_string(),
])
assert_eq(id, "7")
}

nullable values can be written the same way.

///|
test {
let nickname : String? = @maru.first_of(@maru.from_json(null), [
v => { v.null(); None },
v => Some(v.string().value()),
])
assert_eq(nickname, None)
}

#Raw JSON

Value.raw() is the escape hatch for types maru does not convert directly. Typed[T] has no raw().

Invalid

A validation failure, carrying the JSON path where it occurred.

Length

using @kosei28/maru/typed { trait Length }

Types that have a length, used by nonempty, min_len, max_len, and len.

Object

using @kosei28/maru/json { type Object }

An object together with the path used in error messages.

Parser

A function that parses a Value into T.

Path

using @kosei28/maru/error { type Path }

A location inside a JSON value.

Field names are joined with ., and array indices are written as [n]. The root path is empty.

Remainder

Numbers that support remainder, used by multiple_of.

Typed

using @kosei28/maru/typed { type Typed }

A converted value that still carries its JSON path.

Value

using @kosei28/maru/json { type Value }

A JSON value together with the path used in error messages.

Zero

using @kosei28/maru/typed { trait Zero }

Numeric zero, used by positive and related checks.

first_of

Try each parser in order and return the first success.

Raises when every parser fails.

from_json

fn from_json(json : Json) ->
Value

Wrap JSON as a Value so it can be validated while a typed value is built.

Source Files