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
    13 days ago
    Downloads
    13

    #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