diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..e851620 --- /dev/null +++ b/manifest.toml @@ -0,0 +1,11 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "gleam_stdlib", version = "0.40.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86606B75A600BBD05E539EB59FABC6E307EEEA7B1E5865AFB6D980A93BCB2181" }, + { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, +] + +[requirements] +gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/src/toy.gleam b/src/toy.gleam index 28c2b04..a6e0c53 100644 --- a/src/toy.gleam +++ b/src/toy.gleam @@ -1,5 +1,323 @@ -import gleam/io +import gleam/dict +import gleam/dynamic +import gleam/list +import gleam/result +import gleam/string -pub fn main() { - io.println("Hello from toy!") +pub type Decoder(a) = + fn(dynamic.Dynamic) -> #(a, Result(a, List(ToyError))) + +pub type RecordDecoder(a) = + fn(dict.Dict(dynamic.Dynamic, dynamic.Dynamic)) -> + #(a, Result(a, List(ToyError))) + +pub type ToyError { + ToyError(error: ToyFieldError, path: List(String)) +} + +pub type ToyFieldError { + InvalidType(expected: String, found: String) + Missing + IntTooSmall(value: Int, minimum: Int) + IntTooLarge(value: Int, maximum: Int) + IntOutsideRange(value: Int, minimum: Int, maximum: Int) + FloatTooSmall(value: Float, minimum: Float) + FloatTooLarge(value: Float, maximum: Float) + FloatOutsideRange(value: Float, minimum: Float, maximum: Float) + NotOneOf(value: String, all: List(String)) + Custom(tag: String, data: dynamic.Dynamic) +} + +fn from_stdlib_errors(errors: List(dynamic.DecodeError)) -> List(ToyError) { + list.map(errors, fn(err) { + ToyError(error: InvalidType(err.expected, err.found), path: err.path) + }) +} + +pub fn record(next: fn() -> RecordDecoder(b)) -> Decoder(b) { + fn(data) { + case dynamic.dict(dynamic.dynamic, dynamic.dynamic)(data) { + Ok(dict_data) -> { + next()(dict_data) + } + Error(errors) -> { + let #(next_default, _result) = next()(dict.new()) + #(next_default, Error(from_stdlib_errors(errors))) + } + } + } +} + +fn prepend_path(errors: List(ToyError), path: List(String)) -> List(ToyError) { + list.map(errors, fn(err) { + ToyError(..err, path: list.append(path, err.path)) + }) +} + +pub fn field( + key: c, + decoder: Decoder(a), + next: fn(a) -> RecordDecoder(b), +) -> RecordDecoder(b) { + fn(data) { + case dict.get(data, dynamic.from(key)) { + Ok(value) -> { + case decoder(value) { + #(_next_default, Ok(value)) -> next(value)(data) + #(default, Error(errors)) -> { + let #(next_default, result) = next(default)(data) + + let errors = prepend_path(errors, [string.inspect(key)]) + + let new_result = case result { + Ok(_value) -> Error(errors) + Error(next_errors) -> Error(list.append(next_errors, errors)) + } + + #(next_default, new_result) + } + } + } + Error(Nil) -> { + let #(default, _) = decoder(dynamic.from(Nil)) + + let err = ToyError(error: Missing, path: [string.inspect(key)]) + let #(next_default, result) = next(default)(data) + let new_result = case result { + Ok(_value) -> Error([err]) + Error(next_errors) -> Error([err, ..next_errors]) + } + + #(next_default, new_result) + } + } + } +} + +pub fn decoded_record(value: a) -> RecordDecoder(a) { + fn(_) { #(value, Ok(value)) } +} + +pub fn string(data) { + #("", dynamic.string(data) |> result.map_error(from_stdlib_errors)) +} + +pub fn int(data) { + #(0, dynamic.int(data) |> result.map_error(from_stdlib_errors)) +} + +pub fn float(data) { + #(0.0, dynamic.float(data) |> result.map_error(from_stdlib_errors)) +} + +pub fn bit_array(data) { + #(<<>>, dynamic.bit_array(data) |> result.map_error(from_stdlib_errors)) +} + +pub fn dynamic(data) { + #(dynamic.from(Nil), Ok(data)) +} + +pub fn list(item: Decoder(a)) -> Decoder(List(a)) { + fn(data) { + case dynamic.shallow_list(data) { + Ok(value) -> { + let result = + list.try_map(value, fn(val) { + case item(val) { + #(_default, Ok(it)) -> Ok(it) + #(_default, Error(errors)) -> Error(errors) + } + }) + + #([], result) + } + Error(errors) -> #([], Error(from_stdlib_errors(errors))) + } + } +} + +// Int validation + +pub fn int_min(dec: Decoder(Int), minimum: Int) -> Decoder(Int) { + fn(data) { + case dec(data) { + #(default, Ok(data)) -> + case data >= minimum { + True -> #(default, Ok(data)) + False -> #( + default, + Error([ + ToyError(error: IntTooSmall(value: data, minimum:), path: []), + ]), + ) + } + with_decode_error -> with_decode_error + } + } +} + +pub fn int_max(dec: Decoder(Int), maximum: Int) -> Decoder(Int) { + fn(data) { + case dec(data) { + #(default, Ok(data)) -> + case data < maximum { + True -> #(default, Ok(data)) + False -> #( + default, + Error([ + ToyError(error: IntTooLarge(value: data, maximum:), path: []), + ]), + ) + } + with_decode_error -> with_decode_error + } + } +} + +pub fn int_range(dec: Decoder(Int), minimum: Int, maximum: Int) -> Decoder(Int) { + fn(data) { + case dec(data) { + #(default, Ok(data)) -> + case data >= minimum && data < maximum { + True -> #(default, Ok(data)) + False -> #( + default, + Error([ + ToyError( + error: IntOutsideRange(value: data, minimum:, maximum:), + path: [], + ), + ]), + ) + } + with_decode_error -> with_decode_error + } + } +} + +// Float validation + +pub fn float_min(dec: Decoder(Float), minimum: Float) -> Decoder(Float) { + fn(data) { + case dec(data) { + #(default, Ok(data)) -> + case data >=. minimum { + True -> #(default, Ok(data)) + False -> #( + default, + Error([ + ToyError(error: FloatTooSmall(value: data, minimum:), path: []), + ]), + ) + } + with_decode_error -> with_decode_error + } + } +} + +pub fn float_max(dec: Decoder(Float), maximum: Float) -> Decoder(Float) { + fn(data) { + case dec(data) { + #(default, Ok(data)) -> + case data <. maximum { + True -> #(default, Ok(data)) + False -> #( + default, + Error([ + ToyError(error: FloatTooLarge(value: data, maximum:), path: []), + ]), + ) + } + with_decode_error -> with_decode_error + } + } +} + +pub fn float_range( + dec: Decoder(Float), + minimum: Float, + maximum: Float, +) -> Decoder(Float) { + fn(data) { + case dec(data) { + #(default, Ok(data)) -> + case data >=. minimum && data <. maximum { + True -> #(default, Ok(data)) + False -> #( + default, + Error([ + ToyError( + error: FloatOutsideRange(value: data, minimum:, maximum:), + path: [], + ), + ]), + ) + } + with_decode_error -> with_decode_error + } + } +} + +// Generic validation + +pub fn map(dec: Decoder(a), fun: fn(a) -> b) -> Decoder(b) { + fn(data) { + case dec(data) { + #(_default, Ok(data)) -> { + let new_val = fun(data) + #(new_val, Ok(new_val)) + } + #(default, Error(errors)) -> #(fun(default), Error(errors)) + } + } +} + +pub fn refine( + dec: Decoder(a), + fun: fn(a) -> Result(Nil, List(ToyError)), +) -> Decoder(a) { + fn(data) { + case dec(data) { + #(default, Ok(data)) -> { + case fun(data) { + Ok(Nil) -> #(default, Ok(data)) + Error(errors) -> #(default, Error(errors)) + } + } + with_decode_error -> with_decode_error + } + } +} + +pub fn try_map( + dec: Decoder(a), + default: b, + fun: fn(a) -> Result(b, List(ToyError)), +) -> Decoder(b) { + fn(data) { + case dec(data) { + #(_default, Ok(data)) -> + case fun(data) { + Ok(new_value) -> #(default, Ok(new_value)) + Error(errors) -> #(default, Error(errors)) + } + #(_default, Error(errors)) -> #(default, Error(errors)) + } + } +} + +pub fn decode( + data: dynamic.Dynamic, + decoder: Decoder(a), +) -> Result(a, List(ToyError)) { + decoder(data).1 |> result.map_error(list.reverse) +} + +pub fn fail(error: ToyFieldError, default: b) -> Decoder(b) { + fn(_data) { #(default, Error([ToyError(error:, path: [])])) } +} + +pub fn fail_record(error: ToyFieldError, default: b) -> RecordDecoder(b) { + fn(_data) { #(default, Error([ToyError(error:, path: [])])) } } diff --git a/test/toy_test.gleam b/test/toy_test.gleam index 3831e7a..e53353f 100644 --- a/test/toy_test.gleam +++ b/test/toy_test.gleam @@ -1,12 +1,244 @@ +import gleam/dict +import gleam/dynamic +import gleam/int +import gleam/string import gleeunit import gleeunit/should +import toy pub fn main() { gleeunit.main() } -// gleeunit test functions end in `_test` -pub fn hello_world_test() { - 1 - |> should.equal(1) +pub fn string_test() { + let data = dynamic.from("Hello, world!") + toy.decode(data, toy.string) |> should.equal(Ok("Hello, world!")) +} + +pub fn string_invalid_test() { + let data = dynamic.from(42) + toy.decode(data, toy.string) + |> should.equal(Error([toy.ToyError(toy.InvalidType("String", "Int"), [])])) +} + +pub fn string_refine_test() { + let data = dynamic.from("thomas@gmail.com") + toy.decode( + data, + toy.string + |> toy.refine(fn(val) { + case string.contains(val, "@") { + True -> Ok(Nil) + False -> + Error([ + toy.ToyError( + error: toy.Custom("invalid_email", dynamic.from(Nil)), + path: [], + ), + ]) + } + }), + ) + |> should.equal(Ok("thomas@gmail.com")) +} + +pub fn string_try_map_test() { + let data = dynamic.from("123") + toy.decode( + data, + toy.string + |> toy.try_map(0, fn(value) { + case int.parse(value) { + Ok(val) -> Ok(val) + Error(Nil) -> + Error([ + toy.ToyError( + error: toy.Custom("int_parse_failed", dynamic.from(Nil)), + path: [], + ), + ]) + } + }), + ) +} + +pub fn int_map_test() { + let data = dynamic.from(42) + toy.decode(data, toy.int |> toy.map(fn(val) { val + 1 })) + |> should.equal(Ok(43)) +} + +pub type Address { + Address(street: String, city: String, zip: Int) +} + +pub type Friend { + Friend(name: String, age: Int, height: Float, address: Address) +} + +pub type User { + User( + name: String, + age: Int, + height: Float, + address: Address, + friends: List(Friend), + ) +} + +pub fn simple_record_test() { + let simple_record_decoder = fn() { + use <- toy.record() + use street <- toy.field("street", toy.string) + use city <- toy.field("city", toy.string) + use zip <- toy.field("zip", toy.int) + toy.decoded_record(Address(street:, city:, zip:)) + } + + let data = + dict.from_list([ + #("street", dynamic.from("123 Main St")), + #("city", dynamic.from("Springfield")), + #("zip", dynamic.from(12_345)), + ]) + |> dynamic.from + + toy.decode(data, simple_record_decoder()) + |> should.equal(Ok(Address("123 Main St", "Springfield", 12_345))) +} + +pub type Sizing { + Automatic + Fixed(width: Float, height: Float) +} + +pub fn simple_union_test() { + let simple_union_decoder = fn() { + use <- toy.record() + use type_ <- toy.field("type", toy.string) + + case type_ { + "automatic" -> { + toy.decoded_record(Automatic) + } + "fixed" -> { + use width <- toy.field("width", toy.float) + use height <- toy.field("height", toy.float) + toy.decoded_record(Fixed(width:, height:)) + } + _ -> + toy.fail_record(toy.NotOneOf(type_, ["automatic", "fixed"]), Automatic) + } + } + + let data = + dict.from_list([ + #("type", dynamic.from("fixed")), + #("width", dynamic.from(23.0)), + #("height", dynamic.from(100.0)), + ]) + |> dynamic.from + + toy.decode(data, simple_union_decoder()) + |> should.equal(Ok(Fixed(width: 23.0, height: 100.0))) +} + +pub fn complex_validated_record_test() { + let address_decoder = fn() { + use city <- toy.field("city", toy.string) + use street <- toy.field("street", toy.string) + use zip <- toy.field( + "zip", + toy.int + |> toy.refine(fn(zip) { + case string.length(int.to_string(zip)) == 5 { + True -> Ok(Nil) + False -> + Error([ + toy.ToyError(toy.Custom("zip_length", dynamic.from(Nil)), []), + ]) + } + }), + ) + toy.decoded_record(Address(street:, city:, zip:)) + } + + let decoder = fn() { + use <- toy.record() + use name <- toy.field("name", toy.string) + use age <- toy.field("age", toy.int |> toy.int_min(10)) + use height <- toy.field("height", toy.float |> toy.float_range(0.0, 300.0)) + use address <- toy.field("address", toy.record(address_decoder)) + use friends <- toy.field( + "friends", + toy.list( + toy.record(fn() { + use name <- toy.field("name", toy.string) + use age <- toy.field("age", toy.int) + use height <- toy.field("height", toy.float) + use address <- toy.field("address", toy.record(address_decoder)) + toy.decoded_record(Friend(name:, age:, height:, address:)) + }), + ), + ) + toy.decoded_record(User(name:, age:, height:, address:, friends:)) + } + + let data = + dict.from_list([ + #("name", dynamic.from("Thomas")), + #("age", dynamic.from(42)), + #("height", dynamic.from(1.8)), + #( + "address", + dict.from_list([ + #("street", dynamic.from("123 Main St")), + #("city", dynamic.from("Springfield")), + #("zip", dynamic.from(12_345)), + ]) + |> dynamic.from, + ), + #( + "friends", + [ + dict.from_list([ + #("name", dynamic.from("Alice")), + #("age", dynamic.from(40)), + #("height", dynamic.from(1.6)), + #( + "address", + dict.from_list([ + #("street", dynamic.from("456 Elm St")), + #("city", dynamic.from("Springfield")), + #("zip", dynamic.from(12_345)), + ]) + |> dynamic.from, + ), + ]) + |> dynamic.from, + ] + |> dynamic.from, + ), + ]) + |> dynamic.from + + toy.decode(data, decoder()) + |> should.equal( + Ok( + User( + name: "Thomas", + age: 42, + height: 1.8, + address: Address("123 Main St", "Springfield", 12_345), + friends: [ + Friend( + name: "Alice", + age: 40, + height: 1.6, + address: Address("456 Elm St", "Springfield", 12_345), + ), + ], + ), + ), + ) }