diff options
author | Louis Pilfold <louis@lpil.uk> | 2024-11-25 22:38:01 +0000 |
---|---|---|
committer | Louis Pilfold <louis@lpil.uk> | 2024-12-22 10:56:21 +0000 |
commit | f5585adfb4a969d9167817041af0e8c4334e84ec (patch) | |
tree | 901a9b1451d24e87fe27d97bb74e36fa1477d8ac | |
parent | d50616d7b5bde1eeab972a989abcd288c4af536b (diff) | |
download | gleam_stdlib-f5585adfb4a969d9167817041af0e8c4334e84ec.tar.gz gleam_stdlib-f5585adfb4a969d9167817041af0e8c4334e84ec.zip |
New decode module
-rw-r--r-- | src/gleam/dynamic.gleam | 20 | ||||
-rw-r--r-- | src/gleam/dynamic/decode.gleam | 983 | ||||
-rw-r--r-- | src/gleam_stdlib_decode_ffi.erl | 44 | ||||
-rw-r--r-- | src/gleam_stdlib_decode_ffi.mjs | 68 | ||||
-rw-r--r-- | test/gleam/dynamic/decode_test.gleam | 905 | ||||
-rw-r--r-- | test/gleam/should.gleam | 2 |
6 files changed, 2009 insertions, 13 deletions
diff --git a/src/gleam/dynamic.gleam b/src/gleam/dynamic.gleam index b90e507..4fa68eb 100644 --- a/src/gleam/dynamic.gleam +++ b/src/gleam/dynamic.gleam @@ -80,19 +80,8 @@ fn decode_bit_array(a: Dynamic) -> Result(BitArray, DecodeErrors) /// // -> Error([DecodeError(expected: "String", found: "Int", path: [])]) /// ``` /// -pub fn string(from data: Dynamic) -> Result(String, DecodeErrors) { - decode_string(data) -} - -fn map_errors( - result: Result(a, DecodeErrors), - f: fn(DecodeError) -> DecodeError, -) -> Result(a, DecodeErrors) { - result.map_error(result, list.map(_, f)) -} - @external(javascript, "../gleam_stdlib.mjs", "decode_string") -fn decode_string(data: Dynamic) -> Result(String, DecodeErrors) { +pub fn string(from data: Dynamic) -> Result(String, DecodeErrors) { bit_array(data) |> map_errors(put_expected(_, "String")) |> result.try(fn(raw) { @@ -104,6 +93,13 @@ fn decode_string(data: Dynamic) -> Result(String, DecodeErrors) { }) } +fn map_errors( + result: Result(a, DecodeErrors), + f: fn(DecodeError) -> DecodeError, +) -> Result(a, DecodeErrors) { + result.map_error(result, list.map(_, f)) +} + fn put_expected(error: DecodeError, expected: String) -> DecodeError { DecodeError(..error, expected: expected) } diff --git a/src/gleam/dynamic/decode.gleam b/src/gleam/dynamic/decode.gleam new file mode 100644 index 0000000..f9c98c6 --- /dev/null +++ b/src/gleam/dynamic/decode.gleam @@ -0,0 +1,983 @@ +//// The `Dynamic` type is used to represent dynamically typed data. That is, data +//// that we don't know the precise type of yet, so we need to introspect the data to +//// see if it is of the desired type before we can use it. Typically data like this +//// would come from user input or from untyped languages such as Erlang or JavaScript. +//// +//// This module provides the `Decoder` type and associated functions, which provides +//// a type-safe and composable way to convert dynamic data into some desired type, +//// or into errors if the data doesn't have the desired structure. +//// +//// The `Decoder` type is generic and has 1 type parameter, which is the type that +//// it attempts to decode. A `Decoder(String)` can be used to decode strings, and a +//// `Decoder(Option(Int))` can be used to decode `Option(Int)`s +//// +//// Decoders work using _runtime reflection_ and the data structures of the target +//// platform. Differences between Erlang and JavaScript data structures may impact +//// your decoders, so it is important to test your decoders on all supported +//// platforms. +//// +//// The decoding technique used by this module was inspired by Juraj Petráš' +//// [Toy](https://github.com/Hackder/toy), Go's `encoding/json`, and Elm's +//// `Json.Decode`. Thank you to them! +//// +//// # Examples +//// +//// Dynamic data may come from various sources and so many different syntaxes could +//// be used to describe or construct them. In these examples a pseudocode +//// syntax is used to describe the data. +//// +//// ## Simple types +//// +//// This module defines decoders for simple data types such as [`string`](#string), +//// [`int`](#int), [`float`](#float), [`bit_array`](#bit_array), and [`bool`](#bool). +//// +//// ```gleam +//// // Data: +//// // "Hello, Joe!" +//// +//// let result = decode.run(data, decode.string) +//// assert result == Ok("Hello, Joe!") +//// ``` +//// +//// ## Lists +//// +//// The [`list`](#list) decoder decodes `List`s. To use it you must construct it by +//// passing in another decoder into the `list` function, which is the decoder that +//// is to be used for the elements of the list, type checking both the list and its +//// elements. +//// +//// ```gleam +//// // Data: +//// // [1, 2, 3, 4] +//// +//// let result = decode.run(data, decode.list(decode.int)) +//// assert result == Ok([1, 2, 3, 4]) +//// ``` +//// +//// On Erlang this decoder can decode from lists, and on JavaScript it can +//// decode from lists as well as JavaScript arrays. +//// +//// ## Options +//// +//// The [`optional`](#optional) decoder is used to decode values that may or may not +//// be present. In other environment these might be called "nullable" values. +//// +//// Like the `list` decoder, the `optional` decoder takes another decoder, +//// which is used to decode the value if it is present. +//// +//// ```gleam +//// // Data: +//// // 12.45 +//// +//// let result = decode.run(data, decode.optional(decode.int)) +//// assert result == Ok(option.Some(12.45)) +//// ``` +//// ```gleam +//// // Data: +//// // null +//// +//// let result = decode.run(data, decode.optional(decode.int)) +//// assert result == Ok(option.None) +//// ``` +//// +//// This decoder knows how to handle multiple different runtime representations of +//// absent values, including `Nil`, `None`, `null`, and `undefined`. +//// +//// ## Dicts +//// +//// The [`dict`](#dict) decoder decodes `Dicts` and contains two other decoders, one +//// for the keys, one for the values. +//// +//// ```gleam +//// // Data: +//// // { "Lucy" -> 10, "Nubi" -> 20 } +//// +//// let result = decode.run(data, decode.dict(decode.string, decode.int)) +//// assert result == Ok(dict.from_list([ +//// #("Lucy", 10), +//// #("Nubi", 20), +//// ])) +//// ``` +//// +//// ## Indexing objects +//// +//// The [`at`](#at) decoder can be used to decode a value that is nested within +//// key-value containers such as Gleam dicts, Erlang maps, or JavaScript objects. +//// +//// ```gleam +//// // Data: +//// // { "one" -> { "two" -> 123 } } +//// +//// let result = decode.run(data, decode.at(["one", "two"], decode.int)) +//// assert result == Ok(123) +//// ``` +//// +//// ## Indexing arrays +//// +//// If you use ints as keys then the [`at`](#at) decoder can be used to index into +//// array-like containers such as Gleam or Erlang tuples, or JavaScript arrays. +//// +//// ```gleam +//// // Data: +//// // ["one", "two", "three"] +//// +//// let result = decode.run(data, decode.at([1], decode.string)) +//// assert result == Ok("two") +//// ``` +//// +//// ## Records +//// +//// Decoding records from dynamic data is more complex and requires combining a +//// decoder for each field and a special constructor that builds your records with +//// the decoded field values. +//// +//// ```gleam +//// // Data: +//// // { +//// // "score" -> 180, +//// // "name" -> "Mel Smith", +//// // "is-admin" -> false, +//// // "enrolled" -> true, +//// // "colour" -> "Red", +//// // } +//// +//// let decoder = { +//// use name <- decode.field("name", decode.string) +//// use score <- decode.field("score", decode.int) +//// use colour <- decode.field("colour", decode.string) +//// use enrolled <- decode.field("enrolled", decode.bool) +//// decode.success(Player(name:, score:, colour:, enrolled:)) +//// } +//// +//// let result = decode.run(data, decoder) +//// assert result == Ok(Player("Mel Smith", 180, "Red", True)) +//// ``` +//// +//// ## Enum variants +//// +//// Imagine you have a custom type where all the variants do not contain any values. +//// +//// ```gleam +//// pub type PocketMonsterType { +//// Fire +//// Water +//// Grass +//// Electric +//// } +//// ``` +//// +//// You might choose to encode these variants as strings, `"fire"` for `Fire`, +//// `"water"` for `Water`, and so on. To decode them you'll need to decode the dynamic +//// data as a string, but then you'll need to decode it further still as not all +//// strings are valid values for the enum. This can be done with the `then` +//// function, which enables running a second decoder after the first one +//// succeeds. +//// +//// ```gleam +//// let decoder = { +//// use decoded_string <- decode.then(decode.string) +//// case decoded_string { +//// // Return succeeding decoders for valid strings +//// "fire" -> decode.success(Fire) +//// "water" -> decode.success(Water) +//// "grass" -> decode.success(Grass) +//// "electric" -> decode.success(Electric) +//// // Return a failing decoder for any other strings +//// _ -> decode.failure(Fire, "PocketMonsterType") +//// } +//// } +//// +//// let result = decode.run(dynamic.from("water"), decoder) +//// assert result == Ok(Water) +//// +//// let result = decode.run(dynamic.from("wobble"), decoder) +//// assert result == Error([DecodeError("PocketMonsterType", "String", [])]) +//// ``` +//// +//// ## Record variants +//// +//// Decoding type variants that contain other values is done by combining the +//// techniques from the "enum variants" and "records" examples. Imagine you have +//// this custom type that you want to decode: +//// +//// ```gleam +//// pub type PocketMonsterPerson { +//// Trainer(name: String, badge_count: Int) +//// GymLeader(name: String, speciality: PocketMonsterType) +//// } +//// ``` +//// And you would like to be able to decode these from dynamic data like this: +//// ```erlang +//// { +//// "type" -> "trainer", +//// "name" -> "Ash", +//// "badge-count" -> 1, +//// } +//// ``` +//// ```erlang +//// { +//// "type" -> "gym-leader", +//// "name" -> "Misty", +//// "speciality" -> "water", +//// } +//// ``` +//// +//// Notice how both documents have a `"type"` field, which is used to indicate which +//// variant the data is for. +//// +//// First, define decoders for each of the variants: +//// +//// ```gleam +//// let trainer_decoder = { +//// use name <- decode.field("name", decode.string) +//// use badge_count <- decode.field("badge-count", decode.int) +//// decode.success(Trainer(name, badge_count)) +//// }) +//// +//// let gym_leader_decoder = { +//// use name <- decode.field("name", decode.string) +//// use speciality <- decode.field("speciality", pocket_monster_type_decoder) +//// decode.success(GymLeader(name, speciality)) +//// } +//// ``` +//// +//// A third decoder can be used to extract and decode the `"type"` field, and the +//// `then` function then returns whichever decoder is suitable for the document. +//// +//// ```gleam +//// let decoder = { +//// use tag <- decode.field("type", decode.string) +//// case tag { +//// "gym-leader" -> gym_leader_decoder +//// _ -> trainer_decoder +//// } +//// } +//// +//// decode.run(data, decoder) +//// ``` + +import gleam/dict.{type Dict} +import gleam/dynamic.{type DecodeError, DecodeError} +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result + +/// `Dynamic` data is data that we don't know the type of yet, originating from +/// external untyped systems. +/// +/// You should never be converting your well typed data to dynamic data. +/// +pub type Dynamic = + dynamic.Dynamic + +/// A decoder is a value that can be used to turn dynamically typed `Dynamic` +/// data into typed data using the `run` function. +/// +/// Several smaller decoders can be combined to make larger decoders using +/// functions such as `list` and `field`. +/// +pub opaque type Decoder(t) { + Decoder(function: fn(Dynamic) -> #(t, List(dynamic.DecodeError))) +} + +/// The same as [`field`](#field), except taking a path to the value rather +/// than a field name. +/// +/// This function will index into dictionaries with any key type, and if the key is +/// an int then it'll also index into Erlang tuples and JavaScript arrays, and +/// the first two elements of Gleam lists. +/// +/// # Examples +/// +/// ```gleam +/// let data = dynamic.from(dict.from_list([ +/// #("data", dict.from_list([ +/// #("email", "lucy@example.com"), +/// #("name", "Lucy"), +/// ])) +/// ])) +/// +/// let decoder = { +/// use name <- decode.subfield(["data", "name"], decode.string) +/// use email <- decode.subfield(["data", "email"], decode.string) +/// decode.success(SignUp(name: name, email: email)) +/// } +/// let result = decode.run(data, decoder) +/// assert result == Ok(SignUp(name: "Lucy", email: "lucy@example.com")) +/// ``` +/// +pub fn subfield( + field_path: List(name), + field_decoder: Decoder(t), + next: fn(t) -> Decoder(final), +) -> Decoder(final) { + Decoder(function: fn(data) { + let #(out, errors1) = + index(field_path, [], field_decoder.function, data, fn(data, position) { + let #(default, _) = field_decoder.function(data) + #(default, [DecodeError("Field", "Nothing", [])]) + |> push_path(list.reverse(position)) + }) + let #(out, errors2) = next(out).function(data) + #(out, list.append(errors1, errors2)) + }) +} + +/// Run a decoder on a `Dynamic` value, decoding the value if it is of the +/// desired type, or returning errors. +/// +/// # Examples +/// +/// ```gleam +/// let decoder = { +/// use name <- decode.field("email", decode.string) +/// use email <- decode.field("password", decode.string) +/// decode.success(SignUp(name: name, email: email)) +/// } +/// +/// decode.run(data, decoder) +/// ``` +/// +pub fn run( + data: Dynamic, + decoder: Decoder(t), +) -> Result(t, List(dynamic.DecodeError)) { + let #(maybe_invalid_data, errors) = decoder.function(data) + case errors { + [] -> Ok(maybe_invalid_data) + _ -> Error(errors) + } +} + +/// A decoder that decodes a value that is nested within other values. For +/// example, decoding a value that is within some deeply nested JSON objects. +/// +/// This function will index into dictionaries with any key type, and if the key is +/// an int then it'll also index into Erlang tuples and JavaScript arrays, and +/// the first two elements of Gleam lists. +/// +/// # Examples +/// +/// ```gleam +/// let decoder = decode.at(["one", "two"], decode.int) +/// +/// let data = dynamic.from(dict.from_list([ +/// #("one", dict.from_list([ +/// #("two", 1000), +/// ])), +/// ])) +/// +/// +/// decode.run(data, decoder) +/// // -> Ok(1000) +/// ``` +/// +/// ```gleam +/// dynamic.from(Nil) +/// |> decode.run(decode.optional(decode.int)) +/// // -> Ok(option.None) +/// ``` +/// +pub fn at(path: List(segment), inner: Decoder(a)) -> Decoder(a) { + Decoder(function: fn(data) { + index(path, [], inner.function, data, fn(data, position) { + let #(default, _) = inner.function(data) + #(default, [DecodeError("Field", "Nothing", [])]) + |> push_path(list.reverse(position)) + }) + }) +} + +fn index( + path: List(a), + position: List(a), + inner: fn(Dynamic) -> #(b, List(dynamic.DecodeError)), + data: Dynamic, + handle_miss: fn(Dynamic, List(a)) -> #(b, List(dynamic.DecodeError)), +) -> #(b, List(dynamic.DecodeError)) { + case path { + [] -> { + inner(data) + |> push_path(list.reverse(position)) + } + + [key, ..path] -> { + case bare_index(data, key) { + Ok(Some(data)) -> { + index(path, [key, ..position], inner, data, handle_miss) + } + Ok(None) -> { + handle_miss(data, [key, ..position]) + } + Error(kind) -> { + let #(default, _) = inner(data) + #(default, [DecodeError(kind, dynamic.classify(data), [])]) + |> push_path(list.reverse(position)) + } + } + } + } +} + +@external(erlang, "gleam_stdlib_decode_ffi", "strict_index") +@external(javascript, "../../gleam_stdlib_decode_ffi.mjs", "strict_index") +fn bare_index(data: Dynamic, key: anything) -> Result(Option(Dynamic), String) + +fn push_path( + layer: #(t, List(DecodeError)), + path: List(key), +) -> #(t, List(DecodeError)) { + let decoder = + dynamic.any([ + dynamic.string, + fn(x) { result.map(dynamic.int(x), int.to_string) }, + ]) + let path = + list.map(path, fn(key) { + let key = dynamic.from(key) + case decoder(key) { + Ok(key) -> key + Error(_) -> "<" <> dynamic.classify(key) <> ">" + } + }) + let errors = + list.map(layer.1, fn(error) { + DecodeError(..error, path: list.append(path, error.path)) + }) + #(layer.0, errors) +} + +/// Finalise a decoder having successfully extracted a value. +/// +/// # Examples +/// +/// ```gleam +/// let data = dynamic.from(dict.from_list([ +/// #("email", "lucy@example.com"), +/// #("name", "Lucy"), +/// ])) +/// +/// let decoder = { +/// use name <- decode.field("name", string) +/// use email <- decode.field("email", string) +/// decode.success(SignUp(name: name, email: email)) +/// } +/// +/// let result = decode.run(data, decoder) +/// assert result == Ok(SignUp(name: "Lucy", email: "lucy@example.com")) +/// ``` +/// +pub fn success(data: t) -> Decoder(t) { + Decoder(function: fn(_) { #(data, []) }) +} + +/// Construct a decode error for some unexpected dynamic data. +/// +pub fn decode_error( + expected expected: String, + found found: Dynamic, +) -> List(dynamic.DecodeError) { + [DecodeError(expected: expected, found: dynamic.classify(found), path: [])] +} + +/// Run a decoder on a field of a `Dynamic` value, decoding the value if it is +/// of the desired type, or returning errors. An error is returned if there is +/// no field for the specified key. +/// +/// This function will index into dictionaries with any key type, and if the key is +/// an int then it'll also index into Erlang tuples and JavaScript arrays, and +/// the first two elements of Gleam lists. +/// +/// # Examples +/// +/// ```gleam +/// let data = dynamic.from(dict.from_list([ +/// #("email", "lucy@example.com"), +/// #("name", "Lucy"), +/// ])) +/// +/// let decoder = { +/// use name <- decode.field("name", string) +/// use email <- decode.field("email", string) +/// SignUp(name: name, email: email) +/// } +/// +/// let result = decode.run(data, decoder) +/// assert result == Ok(SignUp(name: "Lucy", email: "lucy@example.com")) +/// ``` +/// +/// If you wish to decode a value that is more deeply nested within the dynamic +/// data, see [`subfield`](#subfield) and [`at`](#at). +/// +/// If you wish to return a default in the event that a field is not present, +/// see [`optional_field`](#optional_field) and / [`optionally_at`](#optionally_at). +/// +pub fn field( + field_name: name, + field_decoder: Decoder(t), + next: fn(t) -> Decoder(final), +) -> Decoder(final) { + subfield([field_name], field_decoder, next) +} + +/// Run a decoder on a field of a `Dynamic` value, decoding the value if it is +/// of the desired type, or returning errors. The given default value is +/// returned if there is no field for the specified key. +/// +/// This function will index into dictionaries with any key type, and if the key is +/// an int then it'll also index into Erlang tuples and JavaScript arrays, and +/// the first two elements of Gleam lists. +/// +/// # Examples +/// +/// ```gleam +/// let data = dynamic.from(dict.from_list([ +/// #("name", "Lucy"), +/// ])) +/// +/// let decoder = { +/// use name <- decode.field("name", string) +/// use email <- decode.optional_field("email", "n/a", string) +/// SignUp(name: name, email: email) +/// } +/// +/// let result = decode.run(data, decoder) +/// assert result == Ok(SignUp(name: "Lucy", email: "n/a")) +/// ``` +/// +pub fn optional_field( + key: name, + default: t, + field_decoder: Decoder(t), + next: fn(t) -> Decoder(final), +) -> Decoder(final) { + Decoder(function: fn(data) { + let #(out, errors1) = case bare_index(data, key) { + Ok(Some(data)) -> field_decoder.function(data) + Ok(None) -> #(default, []) + Error(kind) -> { + #(default, [DecodeError(kind, dynamic.classify(data), [])]) + |> push_path([key]) + } + } + let #(out, errors2) = next(out).function(data) + #(out, list.append(errors1, errors2)) + }) +} + +/// A decoder that decodes a value that is nested within other values. For +/// example, decoding a value that is within some deeply nested JSON objects. +/// +/// This function will index into dictionaries with any key type, and if the key is +/// an int then it'll also index into Erlang tuples and JavaScript arrays, and +/// the first two elements of Gleam lists. +/// +/// # Examples +/// +/// ```gleam +/// let decoder = decode.optionally_at(["one", "two"], 100, decode.int) +/// +/// let data = dynamic.from(dict.from_list([ +/// #("one", dict.from_list([])), +/// ])) +/// +/// +/// decode.run(data, decoder) +/// // -> Ok(100) +/// ``` +/// +pub fn optionally_at( + path: List(segment), + default: a, + inner: Decoder(a), +) -> Decoder(a) { + Decoder(function: fn(data) { + index(path, [], inner.function, data, fn(_, _) { #(default, []) }) + }) +} + +fn run_dynamic_function( + data: Dynamic, + zero: t, + f: dynamic.Decoder(t), +) -> #(t, List(dynamic.DecodeError)) { + case f(data) { + Ok(data) -> #(data, []) + Error(errors) -> #(zero, errors) + } +} + +/// A decoder that decodes `String` values. +/// +/// # Examples +/// +/// ```gleam +/// let result = decode.run(dynamic.from("Hello!"), decode.string) +/// assert result == Ok("Hello!") +/// ``` +/// +pub const string: Decoder(String) = Decoder(decode_string) + +fn decode_string(data: Dynamic) -> #(String, List(dynamic.DecodeError)) { + run_dynamic_function(data, "", dynamic.string) +} + +/// A decoder that decodes `Bool` values. +/// +/// # Examples +/// +/// ```gleam +/// let result = decode.run(dynamic.from(True), decode.bool) +/// assert result == Ok(True) +/// ``` +/// +pub const bool: Decoder(Bool) = Decoder(decode_bool) + +fn decode_bool(data: Dynamic) -> #(Bool, List(dynamic.DecodeError)) { + run_dynamic_function(data, False, dynamic.bool) +} + +/// A decoder that decodes `Int` values. +/// +/// # Examples +/// +/// ```gleam +/// let result = decode.run(dynamic.from(147), decode.int) +/// assert result == Ok(147) +/// ``` +/// +pub const int: Decoder(Int) = Decoder(decode_int) + +fn decode_int(data: Dynamic) -> #(Int, List(dynamic.DecodeError)) { + run_dynamic_function(data, 0, dynamic.int) +} + +/// A decoder that decodes `Float` values. +/// +/// # Examples +/// +/// ```gleam +/// let result = decode.run(dynamic.from(3.14), decode.float) +/// assert result == Ok(3.14) +/// ``` +/// +pub const float: Decoder(Float) = Decoder(decode_float) + +fn decode_float(data: Dynamic) -> #(Float, List(dynamic.DecodeError)) { + run_dynamic_function(data, 0.0, dynamic.float) +} + +/// A decoder that decodes `Dynamic` values. This decoder never returns an error. +/// +/// # Examples +/// +/// ```gleam +/// let result = decode.run(dynamic.from(3.14), decode.dynamic) +/// assert result == Ok(dynamic.from(3.14)) +/// ``` +/// +pub const dynamic: Decoder(Dynamic) = Decoder(decode_dynamic) + +fn decode_dynamic(data: Dynamic) -> #(Dynamic, List(dynamic.DecodeError)) { + #(data, []) +} + +/// A decoder that decodes `BitArray` values. This decoder never returns an error. +/// +/// # Examples +/// +/// ```gleam +/// let result = decode.run(dynamic.from(<<5, 7>>), decode.bit_array) +/// assert result == Ok(<<5, 7>>) +/// ``` +/// +pub const bit_array: Decoder(BitArray) = Decoder(decode_bit_array) + +fn decode_bit_array(data: Dynamic) -> #(BitArray, List(dynamic.DecodeError)) { + run_dynamic_function(data, <<>>, dynamic.bit_array) +} + +/// A decoder that decodes lists where all elements are decoded with a given +/// decoder. +/// +/// # Examples +/// +/// ```gleam +/// let result = +/// decode.run(dynamic.from([1, 2, 3]), decode.list(of: decode.int)) +/// assert result == Ok([1, 2, 3]) +/// ``` +/// +pub fn list(of inner: Decoder(a)) -> Decoder(List(a)) { + Decoder(fn(data) { + decode_list(data, inner.function, fn(p, k) { push_path(p, [k]) }, 0, []) + }) +} + +@external(erlang, "gleam_stdlib_decode_ffi", "list") +@external(javascript, "../../gleam_stdlib_decode_ffi.mjs", "list") +fn decode_list( + data: Dynamic, + item: fn(Dynamic) -> #(t, List(dynamic.DecodeError)), + push_path: fn(#(t, List(DecodeError)), key) -> #(t, List(DecodeError)), + index: Int, + acc: List(t), +) -> #(List(t), List(dynamic.DecodeError)) + +/// A decoder that decodes dicts where all keys and vales are decoded with +/// given decoders. +/// +/// # Examples +/// +/// ```gleam +/// let values = dict.from_list([ +/// #("one", 1), +/// #("two", 2), +/// ]) +/// +/// let result = +/// decode.run(dynamic.from(values), decode.dict(decode.string, decode.int)) +/// assert result == Ok(values) +/// ``` +/// +pub fn dict( + key: Decoder(key), + value: Decoder(value), +) -> Decoder(Dict(key, value)) { + Decoder(fn(data) { + case decode_dict(data) { + Error(_) -> #(dict.new(), decode_error("Dict", data)) + Ok(dict) -> + dict.fold(dict, #(dict.new(), []), fn(a, k, v) { + // If there are any errors from previous key-value pairs then we + // don't need to run the decoders, instead return the existing acc. + case a.1 { + [] -> fold_dict(a, k, v, key.function, value.function) + _ -> a + } + }) + } + }) +} + +fn fold_dict( + acc: #(Dict(k, v), List(dynamic.DecodeError)), + key: Dynamic, + value: Dynamic, + key_decoder: fn(Dynamic) -> #(k, List(dynamic.DecodeError)), + value_decoder: fn(Dynamic) -> #(v, List(dynamic.DecodeError)), +) -> #(Dict(k, v), List(dynamic.DecodeError)) { + // First we decode the key. + case key_decoder(key) { + #(key, []) -> + // Then we decode the value. + case value_decoder(value) { + #(value, []) -> { + // It worked! Insert the new key-value pair so we can move onto the next. + let dict = dict.insert(acc.0, key, value) + #(dict, acc.1) + } + #(_, errors) -> push_path(#(dict.new(), errors), ["values"]) + } + #(_, errors) -> push_path(#(dict.new(), errors), ["keys"]) + } +} + +@external(erlang, "gleam_stdlib_decode_ffi", "dict") +@external(javascript, "../../gleam_stdlib_decode_ffi.mjs", "dict") +fn decode_dict(data: Dynamic) -> Result(Dict(Dynamic, Dynamic), Nil) + +/// A decoder that decodes nullable values of a type decoded by with a given +/// decoder. +/// +/// This function can handle common representations of null on all runtimes, such as +/// `nil`, `null`, and `undefined` on Erlang, and `undefined` and `null` on +/// JavaScript. +/// +/// # Examples +/// +/// ```gleam +/// let result = decode.run(dynamic.from(100), decode.optional(decode.int)) +/// assert result == Ok(option.Some(100)) +/// ``` +/// +/// ```gleam +/// let result = decode.run(dynamic.from(Nil), decode.optional(decode.int)) +/// assert result == Ok(option.None) +/// ``` +/// +pub fn optional(inner: Decoder(a)) -> Decoder(Option(a)) { + Decoder(function: fn(data) { + case dynamic.optional(Ok)(data) { + Ok(option.None) -> #(option.None, []) + Ok(option.Some(data)) -> { + let #(data, errors) = inner.function(data) + #(option.Some(data), errors) + } + Error(_) -> { + let #(data, errors) = inner.function(data) + #(option.Some(data), errors) + } + } + }) +} + +/// Apply a transformation function to any value decoded by the decoder. +/// +/// # Examples +/// +/// ```gleam +/// let decoder = decode.int |> decode.map(int.to_string) +/// let result = decode.run(dynamic.from(1000), decoder) +/// assert result == Ok("1000") +/// ``` +/// +pub fn map(decoder: Decoder(a), transformer: fn(a) -> b) -> Decoder(b) { + Decoder(function: fn(d) { + let #(data, errors) = decoder.function(d) + #(transformer(data), errors) + }) +} + +/// Apply a transformation function to any errors returned by the decoder. +/// +pub fn map_errors( + decoder: Decoder(a), + transformer: fn(List(DecodeError)) -> List(DecodeError), +) -> Decoder(a) { + Decoder(function: fn(d) { + let #(data, errors) = decoder.function(d) + #(data, transformer(errors)) + }) +} + +/// Replace all errors produced by a decoder with one single error for a named +/// expected type. +/// +/// This function may be useful if you wish to simplify errors before +/// presenting them to a user, particularly when using the `one_of` function. +/// +/// # Examples +/// +/// ```gleam +/// let decoder = decode.string |> decode.collapse_errors("MyThing") +/// let result = decode.run(dynamic.from(1000), decoder) +/// assert result == Error([DecodeError("MyThing", "Int", [])]) +/// ``` +/// +pub fn collapse_errors(decoder: Decoder(a), name: String) -> Decoder(a) { + Decoder(function: fn(dynamic_data) { + let #(data, errors) as layer = decoder.function(dynamic_data) + case errors { + [] -> layer + _ -> #(data, decode_error(name, dynamic_data)) + } + }) +} + +/// Create a new decoder based upon the value of a previous decoder. +/// +/// This may be useful to run one previous decoder to use in further decoding. +/// +pub fn then(decoder: Decoder(a), next: fn(a) -> Decoder(b)) -> Decoder(b) { + Decoder(function: fn(dynamic_data) { + let #(data, errors) = decoder.function(dynamic_data) + let decoder = next(data) + let #(data, _) as layer = decoder.function(dynamic_data) + case errors { + [] -> layer + _ -> #(data, errors) + } + }) +} + +/// Create a new decoder from several other decoders. Each of the inner +/// decoders is run in turn, and the value from the first to succeed is used. +/// +/// If no decoder succeeds then the errors from the first decoder is used. +/// If you wish for different errors then you may wish to use the +/// `collapse_errors` or `map_errors` functions. +/// +/// # Examples +/// +/// ```gleam +/// decode.one_of(decode.string, or: [ +/// decode.int |> decode.map(int.to_string), +/// decode.float |> decode.map(float.to_string), +/// ]) +/// |> decode.run(dynamic.from(1000)) +/// // -> Ok("1000") +/// ``` +/// +pub fn one_of( + first: Decoder(a), + or alternatives: List(Decoder(a)), +) -> Decoder(a) { + Decoder(function: fn(dynamic_data) { + let #(_, errors) as layer = first.function(dynamic_data) + case errors { + [] -> layer + _ -> run_decoders(dynamic_data, layer, alternatives) + } + }) +} + +fn run_decoders( + data: Dynamic, + failure: #(a, List(DecodeError)), + decoders: List(Decoder(a)), +) -> #(a, List(DecodeError)) { + case decoders { + [] -> failure + + [decoder, ..decoders] -> { + let #(_, errors) as layer = decoder.function(data) + case errors { + [] -> layer + _ -> run_decoders(data, failure, decoders) + } + } + } +} + +/// Define a decoder that always fails. The parameter for this function is the +/// name of the type that has failed to decode. +/// +pub fn failure(zero: a, expected: String) -> Decoder(a) { + Decoder(function: fn(d) { #(zero, decode_error(expected, d)) }) +} + +/// Create a decoder for a new data type from a decoding function. +/// +/// This function is used for new primitive types. For example, you might +/// define a decoder for Erlang's pid type. +/// +/// A default "zero" value is also required to make a decoder. When this +/// decoder is used as part of a larger decoder this zero value used as +/// a placeholder so that the rest of the decoder can continue to run and +/// collect all decoding errors. +/// +/// If you were to make a decoder for the `String` type (rather than using the +/// build-in `string` decoder) you would define it like so: +/// +/// ```gleam +/// import gleam/dynamic +/// import decode/decode +/// +/// pub fn string_decoder() -> decode.Decoder(String) { +/// decode.new_primitive_decoder(dynamic.string, "") +/// } +/// ``` +/// +pub fn new_primitive_decoder( + decoding_function: fn(Dynamic) -> Result(t, List(DecodeError)), + zero: t, +) -> Decoder(t) { + Decoder(function: fn(d) { + case decoding_function(d) { + Ok(t) -> #(t, []) + Error(errors) -> #(zero, errors) + } + }) +} diff --git a/src/gleam_stdlib_decode_ffi.erl b/src/gleam_stdlib_decode_ffi.erl new file mode 100644 index 0000000..35af05e --- /dev/null +++ b/src/gleam_stdlib_decode_ffi.erl @@ -0,0 +1,44 @@ +-module(gleam_stdlib_decode_ffi). + +-export([strict_index/2, list/5, dict/1]). + +strict_index([X | _], 0) -> + {ok, {some, X}}; +strict_index([_, X | _], 1) -> + {ok, {some, X}}; +strict_index(Tuple, Index) when is_tuple(Tuple) andalso is_integer(Index) -> + {ok, try + {some, element(Index + 1, Tuple)} + catch _:_ -> + none + end}; +strict_index(Map, Key) when is_map(Map) -> + {ok, try + {some, maps:get(Key, Map)} + catch _:_ -> + none + end}; +strict_index(_, Index) when is_integer(Index) -> + {error, <<"Indexable">>}; +strict_index(_, _) -> + {error, <<"Dict">>}. + +list(T, A, B, C, D) when is_tuple(T) -> + list(tuple_to_list(T), A, B, C, D); +list([], _, _, _, Acc) -> + {lists:reverse(Acc), []}; +list([X | Xs], Decode, PushPath, Index, Acc) -> + {Out, Errors} = Decode(X), + case Errors of + [] -> list(Xs, Decode, PushPath, Index + 1, [Out | Acc]); + _ -> PushPath({[], Errors}, integer_to_binary(Index)) + end; +list(Unexpected, _, _, _, []) -> + Found = gleam@dynamic:classify(Unexpected), + Error = {decode_error, <<"List"/utf8>>, Found, []}, + {[], [Error]}; +list(_, _, _, _, Acc) -> + {lists:reverse(Acc), []}. + +dict(#{} = Data) -> {ok, Data}; +dict(_) -> {error, nil}. diff --git a/src/gleam_stdlib_decode_ffi.mjs b/src/gleam_stdlib_decode_ffi.mjs new file mode 100644 index 0000000..86c9c60 --- /dev/null +++ b/src/gleam_stdlib_decode_ffi.mjs @@ -0,0 +1,68 @@ +import { Ok, Error, List, NonEmpty } from "./gleam.mjs"; +import { default as Dict } from "./dict.mjs"; +import { Some, None } from "./gleam/option.mjs"; +import { DecodeError, classify } from "./gleam/dynamic.mjs"; + +export function strict_index(data, key) { + const int = Number.isInteger(key); + + // Dictionaries and dictionary-like objects can be indexed + if (data instanceof Dict || data instanceof WeakMap || data instanceof Map) { + const token = {}; + const entry = data.get(key, token); + if (entry === token) return new Ok(new None()); + return new Ok(new Some(entry)); + } + + // The first 3 elements of lists can be indexed + if ((key === 0 || key === 1 || key === 2) && data instanceof List) { + let i = 0; + for (const value of data) { + if (i === key) return new Ok(new Some(value)); + i++; + } + return new Error("Indexable"); + } + + // Arrays and objects can be indexed + if ( + (int && Array.isArray(data)) || + (data && typeof data === "object") || + (data && Object.getPrototypeOf(data) === Object.prototype) + ) { + if (key in data) return new Ok(new Some(data[key])); + return new Ok(new None()); + } + + return new Error(int ? "Indexable" : "Dict"); +} + +export function list(data, decode, pushPath, index, emptyList) { + if (!(data instanceof List || Array.isArray(data))) { + let error = new DecodeError("List", classify(data), emptyList); + return [emptyList, List.fromArray([error])]; + } + + const decoded = []; + + for (const element of data) { + const layer = decode(element); + const [out, errors] = layer; + + if (errors instanceof NonEmpty) { + const [_, errors] = pushPath(layer, index.toString()); + return [emptyList, errors]; + } + decoded.push(out); + index++; + } + + return [List.fromArray(decoded), emptyList]; +} + +export function dict(data) { + if (data instanceof Dict) { + return new Ok(data); + } + return new Error(); +} diff --git a/test/gleam/dynamic/decode_test.gleam b/test/gleam/dynamic/decode_test.gleam new file mode 100644 index 0000000..cff3d46 --- /dev/null +++ b/test/gleam/dynamic/decode_test.gleam @@ -0,0 +1,905 @@ +import gleam/dict +import gleam/dynamic.{DecodeError} +import gleam/dynamic/decode +import gleam/float +import gleam/int +import gleam/option +import gleam/should + +pub type User { + User( + name: String, + email: String, + is_admin: Bool, + is_confirmed: Bool, + score: Int, + ) +} + +pub fn decoder_test() { + let data = + dynamic.from( + dict.from_list([ + #("name", dynamic.from("Nubi")), + #("email", dynamic.from("nubi@example.com")), + #("is_admin", dynamic.from(False)), + #("is_confirmed", dynamic.from(True)), + #("score", dynamic.from(180)), + ]), + ) + + let decoder = { + use name <- decode.field("name", decode.string) + use email <- decode.field("email", decode.string) + use is_admin <- decode.field("is_admin", decode.bool) + use is_confirmed <- decode.field("is_confirmed", decode.bool) + use score <- decode.field("score", decode.int) + decode.success(User( + name: name, + email: email, + is_admin: is_admin, + is_confirmed: is_confirmed, + score: score, + )) + } + + decode.run(data, decoder) + |> should.be_ok + |> should.equal(User("Nubi", "nubi@example.com", False, True, 180)) +} + +pub fn field_ok_test() { + let data = dynamic.from(dict.from_list([#("name", dynamic.from("Nubi"))])) + let decoder = { + use name <- decode.field("name", decode.string) + decode.success(name) + } + + decode.run(data, decoder) + |> should.be_ok + |> should.equal("Nubi") +} + +pub fn subfield_ok_test() { + let data = + dynamic.from( + dict.from_list([ + #("person", dict.from_list([#("name", dynamic.from("Nubi"))])), + ]), + ) + let decoder = { + use name <- decode.subfield(["person", "name"], decode.string) + decode.success(name) + } + + decode.run(data, decoder) + |> should.be_ok + |> should.equal("Nubi") +} + +pub fn field_int_index_ok_test() { + let decoder = { + use x <- decode.field(0, decode.string) + use y <- decode.field(1, decode.string) + decode.success(#(x, y)) + } + + dynamic.from(#("one", "two", "three")) + |> decode.run(decoder) + |> should.be_ok + |> should.equal(#("one", "two")) +} + +pub fn field_int_index_list_ok_test() { + let decoder = { + use x <- decode.field(0, decode.string) + use y <- decode.field(1, decode.string) + decode.success(#(x, y)) + } + + dynamic.from(["one", "two", "three", "four"]) + |> decode.run(decoder) + |> should.be_ok + |> should.equal(#("one", "two")) +} + +pub fn subfield_not_found_error_test() { + let decoder = { + use name <- decode.subfield(["name"], decode.string) + decode.success(name) + } + + dynamic.from(123) + |> decode.run(decoder) + |> should.be_error + |> should.equal([DecodeError("Dict", "Int", [])]) +} + +pub fn field_not_found_error_test() { + let decoder = { + use name <- decode.subfield(["name"], decode.string) + decode.success(name) + } + + dynamic.from(123) + |> decode.run(decoder) + |> should.be_error + |> should.equal([DecodeError("Dict", "Int", [])]) +} + +pub fn field_wrong_inner_error_test() { + let decoder = { + use name <- decode.field("name", decode.string) + decode.success(name) + } + + dynamic.from(dict.from_list([#("name", dynamic.from(123))])) + |> decode.run(decoder) + |> should.be_error + |> should.equal([DecodeError("String", "Int", ["name"])]) +} + +pub fn subfield_int_index_ok_test() { + let decoder = { + use x <- decode.subfield([0, 1], decode.string) + use y <- decode.subfield([1, 0], decode.string) + decode.success(#(x, y)) + } + + dynamic.from(#(#("one", "two", "three"), #("a", "b"))) + |> decode.run(decoder) + |> should.be_ok + |> should.equal(#("two", "a")) +} + +pub fn subfield_wrong_inner_error_test() { + let data = dynamic.from(dict.from_list([#("name", dynamic.from(123))])) + decode.run(data, { + use name <- decode.field("name", decode.string) + decode.success(name) + }) + |> should.be_error + |> should.equal([DecodeError("String", "Int", ["name"])]) +} + +pub fn string_ok_test() { + dynamic.from("Hello!") + |> decode.run(decode.string) + |> should.be_ok + |> should.equal("Hello!") +} + +pub fn string_error_test() { + dynamic.from(123) + |> decode.run(decode.string) + |> should.be_error + |> should.equal([DecodeError("String", "Int", [])]) +} + +pub fn dynamic_test() { + let data = dynamic.from(123) + data + |> decode.run(decode.dynamic) + |> should.be_ok + |> should.equal(data) +} + +pub fn int_ok_test() { + dynamic.from(123) + |> decode.run(decode.int) + |> should.be_ok + |> should.equal(123) +} + +pub fn int_error_test() { + dynamic.from("123") + |> decode.run(decode.int) + |> should.be_error + |> should.equal([DecodeError("Int", "String", [])]) +} + +pub fn float_ok_test() { + dynamic.from(123.45) + |> decode.run(decode.float) + |> should.be_ok + |> should.equal(123.45) +} + +pub fn float_error_test() { + dynamic.from("123.45") + |> decode.run(decode.float) + |> should.be_error + |> should.equal([DecodeError("Float", "String", [])]) +} + +pub fn bool_true_test() { + dynamic.from(True) + |> decode.run(decode.bool) + |> should.be_ok + |> should.equal(True) +} + +pub fn bool_false_test() { + dynamic.from(False) + |> decode.run(decode.bool) + |> should.be_ok + |> should.equal(False) +} + +pub fn bool_error_test() { + dynamic.from(123) + |> decode.run(decode.bool) + |> should.be_error + |> should.equal([DecodeError("Bool", "Int", [])]) +} + +pub fn bit_array_ok_test() { + dynamic.from(<<1, 5, 3>>) + |> decode.run(decode.bit_array) + |> should.be_ok + |> should.equal(<<1, 5, 3>>) +} + +pub fn bit_array_error_test() { + dynamic.from(123) + |> decode.run(decode.bit_array) + |> should.be_error + |> should.equal([DecodeError("BitArray", "Int", [])]) +} + +pub fn list_tuple_ok_test() { + dynamic.from(#("Hello", "Joe")) + |> decode.run(decode.list(decode.string)) + |> should.be_ok + |> should.equal(["Hello", "Joe"]) +} + +pub fn list_string_ok_test() { + dynamic.from(["Hello", "Joe"]) + |> decode.run(decode.list(decode.string)) + |> should.be_ok + |> should.equal(["Hello", "Joe"]) +} + +pub fn list_bool_ok_test() { + dynamic.from([True, False]) + |> decode.run(decode.list(decode.bool)) + |> should.be_ok + |> should.equal([True, False]) +} + +pub fn list_error_test() { + dynamic.from(123) + |> decode.run(decode.list(decode.int)) + |> should.be_error + |> should.equal([DecodeError("List", "Int", [])]) +} + +pub fn list_inner_0_error_test() { + dynamic.from([1, 2]) + |> decode.run(decode.list(decode.string)) + |> should.be_error + |> should.equal([DecodeError("String", "Int", ["0"])]) +} + +pub fn list_inner_1_error_test() { + dynamic.from([dynamic.from("1"), dynamic.from(2)]) + |> decode.run(decode.list(decode.string)) + |> should.be_error + |> should.equal([DecodeError("String", "Int", ["1"])]) +} + +pub fn list_tuple_inner_1_error_test() { + dynamic.from(#("1", 2)) + |> decode.run(decode.list(decode.string)) + |> should.be_error + |> should.equal([DecodeError("String", "Int", ["1"])]) +} + +pub fn dict_ok_test() { + let values = dict.from_list([#("first", 1), #("second", 2)]) + dynamic.from(values) + |> decode.run(decode.dict(decode.string, decode.int)) + |> should.be_ok + |> should.equal(values) +} + +pub fn dict_value_error_test() { + dynamic.from(dict.from_list([#(1.1, 1), #(1.2, 2)])) + |> decode.run(decode.dict(decode.float, decode.string)) + |> should.be_error + |> should.equal([DecodeError("String", "Int", ["values"])]) +} + +pub fn dict_key_error_test() { + dynamic.from(dict.from_list([#(1.1, 1), #(1.2, 2)])) + |> decode.run(decode.dict(decode.string, decode.int)) + |> should.be_error + |> should.equal([DecodeError("String", "Float", ["keys"])]) +} + +pub fn dict_error_test() { + dynamic.from(123) + |> decode.run(decode.dict(decode.string, decode.int)) + |> should.be_error + |> should.equal([DecodeError("Dict", "Int", [])]) +} + +pub fn at_dict_string_ok_test() { + dynamic.from( + dict.from_list([ + #( + "first", + dict.from_list([#("second", dict.from_list([#("third", 1337)]))]), + ), + ]), + ) + |> decode.run(decode.at(["first", "second", "third"], decode.int)) + |> should.be_ok + |> should.equal(1337) +} + +pub fn at_dict_int_ok_test() { + dynamic.from( + dict.from_list([ + #(10, dict.from_list([#(20, dict.from_list([#(30, 1337)]))])), + ]), + ) + |> decode.run(decode.at([10, 20, 30], decode.int)) + |> should.be_ok + |> should.equal(1337) +} + +pub fn at_tuple_int_ok_test() { + dynamic.from(#("x", #("a", "b", "c"), "z")) + |> decode.run(decode.at([1, 0], decode.string)) + |> should.be_ok + |> should.equal("a") +} + +pub fn at_wrong_inner_error_test() { + dynamic.from( + dict.from_list([ + #( + "first", + dict.from_list([#("second", dict.from_list([#("third", 1337)]))]), + ), + ]), + ) + |> decode.run(decode.at(["first", "second", "third"], decode.string)) + |> should.be_error + |> should.equal([DecodeError("String", "Int", ["first", "second", "third"])]) +} + +pub fn at_no_path_error_test() { + dynamic.from(dict.from_list([#("first", dict.from_list([#("third", 1337)]))])) + |> decode.run(decode.at(["first", "second", "third"], decode.int)) + |> should.be_error + |> should.equal([DecodeError("Field", "Nothing", ["first", "second"])]) +} + +pub fn optional_string_present_ok_test() { + dynamic.from("Hello, Joe!") + |> decode.run(decode.optional(decode.string)) + |> should.be_ok + |> should.equal(option.Some("Hello, Joe!")) +} + +pub fn optional_bool_present_ok_test() { + dynamic.from(True) + |> decode.run(decode.optional(decode.bool)) + |> should.be_ok + |> should.equal(option.Some(True)) +} + +pub fn optional_bool_absent_nil_ok_test() { + dynamic.from(Nil) + |> decode.run(decode.optional(decode.bool)) + |> should.be_ok + |> should.equal(option.None) +} + +pub fn optional_bool_absent_none_ok_test() { + dynamic.from(option.None) + |> decode.run(decode.optional(decode.bool)) + |> should.be_ok + |> should.equal(option.None) +} + +pub fn optional_error_test() { + dynamic.from(123) + |> decode.run(decode.optional(decode.string)) + |> should.be_error + |> should.equal([DecodeError("String", "Int", [])]) +} + +pub fn map_test() { + dynamic.from(123) + |> decode.run(decode.int |> decode.map(int.to_string)) + |> should.be_ok + |> should.equal("123") +} + +pub fn map_errors_test() { + let decoder = + decode.at( + ["data"], + decode.map_errors(decode.string, fn(errors) { + let assert [DecodeError("String", "Int", [])] = errors + [ + DecodeError("Wibble", "Wobble", ["ok"]), + DecodeError("Wabble", "Wubble", ["ok"]), + ] + }), + ) + + dynamic.from(dict.from_list([#("data", 123)])) + |> decode.run(decoder) + |> should.be_error + |> should.equal([ + DecodeError("Wibble", "Wobble", ["data", "ok"]), + DecodeError("Wabble", "Wubble", ["data", "ok"]), + ]) +} + +pub fn collapse_errors_test() { + dynamic.from(dict.from_list([#("data", 123)])) + |> decode.run(decode.at( + ["data"], + decode.string |> decode.collapse_errors("Wibble"), + )) + |> should.be_error + |> should.equal([DecodeError("Wibble", "Int", ["data"])]) +} + +pub fn then_test() { + let decoder = + decode.at(["key"], decode.int) + |> decode.then(fn(i) { + decode.at(["value"], case i { + 1 -> decode.int |> decode.map(AnInt) + _ -> decode.string |> decode.map(AString) + }) + }) + + dynamic.from(dict.from_list([#("key", 1), #("value", 100)])) + |> decode.run(decoder) + |> should.be_ok + |> should.equal(AnInt(100)) + + dynamic.from( + dict.from_list([#("key", dynamic.from(2)), #("value", dynamic.from("Hi!"))]), + ) + |> decode.run(decoder) + |> should.be_ok + |> should.equal(AString("Hi!")) +} + +type IntOrString { + AnInt(Int) + AString(String) +} + +pub fn then_error_0_test() { + let decoder = + decode.at(["key"], decode.int) + |> decode.then(fn(i) { + decode.at(["value"], case i { + 1 -> decode.int |> decode.map(AnInt) + _ -> decode.string |> decode.map(AString) + }) + }) + + dynamic.from(123) + |> decode.run(decoder) + |> should.be_error + |> should.equal([DecodeError("Dict", "Int", [])]) +} + +pub fn then_error_1_test() { + let decoder = + decode.at(["key"], decode.int) + |> decode.then(fn(i) { + decode.at(["value"], case i { + 1 -> decode.int |> decode.map(AnInt) + _ -> decode.string |> decode.map(AString) + }) + }) + + dynamic.from( + dict.from_list([#("key", dynamic.from(1)), #("value", dynamic.from("Hi!"))]), + ) + |> decode.run(decoder) + |> should.be_error + |> should.equal([DecodeError("Int", "String", ["value"])]) +} + +pub type MyEnum { + A + B + C +} + +pub fn then_enum_test() { + let decoder = + decode.string + |> decode.then(fn(s) { + case s { + "a" -> decode.success(A) + "b" -> decode.success(B) + "c" -> decode.success(C) + _ -> decode.failure(A, "MyEnum") + } + }) + + decode.run(dynamic.from("a"), decoder) + |> should.be_ok + |> should.equal(A) + + decode.run(dynamic.from("b"), decoder) + |> should.be_ok + |> should.equal(B) + + decode.run(dynamic.from("c"), decoder) + |> should.be_ok + |> should.equal(C) + + decode.run(dynamic.from("d"), decoder) + |> should.be_error + |> should.equal([DecodeError("MyEnum", "String", [])]) +} + +pub fn one_of_ok_0_test() { + dynamic.from("Hello!") + |> decode.run( + decode.one_of(decode.string, [decode.int |> decode.map(int.to_string)]), + ) + |> should.be_ok + |> should.equal("Hello!") +} + +pub fn one_of_ok_1_test() { + let decoder = + decode.one_of(decode.string, [ + decode.int + |> decode.map(int.to_string), + ]) + dynamic.from(123) + |> decode.run(decoder) + |> should.be_ok + |> should.equal("123") +} + +pub fn one_of_ok_2_test() { + let decoder = + decode.one_of(decode.string, [ + decode.int |> decode.map(int.to_string), + decode.float |> decode.map(float.to_string), + ]) + dynamic.from(12.45) + |> decode.run(decoder) + |> should.be_ok + |> should.equal("12.45") +} + +pub fn one_of_error_test() { + let decoder = + decode.one_of(decode.string, or: [ + decode.int + |> decode.map(int.to_string), + ]) + dynamic.from(1.2) + |> decode.run(decoder) + |> should.be_error + |> should.equal([DecodeError("String", "Float", [])]) +} + +pub fn failure_test() { + dynamic.from(123) + |> decode.run(decode.failure(1, "WibbleWobble")) + |> should.be_error + |> should.equal([DecodeError("WibbleWobble", "Int", [])]) +} + +pub fn variants_test() { + let decoder = { + use tag <- decode.field("tag", decode.string) + case tag { + "int" -> { + use int <- decode.field("the-int", decode.int) + decode.success(AnInt(int)) + } + "string" -> { + use string <- decode.field("the-string", decode.string) + decode.success(AString(string)) + } + _ -> { + decode.failure(AnInt(0), "IntOrString") + } + } + } + + // Int variant + dynamic.from( + dict.from_list([ + #("tag", dynamic.from("int")), + #("the-int", dynamic.from(123)), + ]), + ) + |> decode.run(decoder) + |> should.be_ok + |> should.equal(AnInt(123)) + + // String variant + dynamic.from( + dict.from_list([ + #("tag", dynamic.from("string")), + #("the-string", dynamic.from("hello")), + ]), + ) + |> decode.run(decoder) + |> should.be_ok + |> should.equal(AString("hello")) + + // Invalid tag + dynamic.from( + dict.from_list([ + #("tag", dynamic.from("dunno")), + #("the-string", dynamic.from("hello")), + ]), + ) + |> decode.run(decoder) + |> should.be_error + |> should.equal([DecodeError("IntOrString", "Dict", [])]) + + // Missing tag + dynamic.from(dict.from_list([#("the-string", dynamic.from("hello"))])) + |> decode.run(decoder) + |> should.be_error + |> should.equal([ + DecodeError("Field", "Nothing", ["tag"]), + DecodeError("IntOrString", "Dict", []), + ]) + + // String invalid field + dynamic.from( + dict.from_list([ + #("tag", dynamic.from("string")), + #("the-string", dynamic.from(12.3)), + ]), + ) + |> decode.run(decoder) + |> should.be_error + |> should.equal([DecodeError("String", "Float", ["the-string"])]) +} + +pub type PocketMonsterType { + Fire + Water + Grass + Electric +} + +pub fn documentation_enum_example_test() { + let decoder = { + use decoded_string <- decode.then(decode.string) + case decoded_string { + // Return succeeding decoders for valid strings + "fire" -> decode.success(Fire) + "water" -> decode.success(Water) + "grass" -> decode.success(Grass) + "electric" -> decode.success(Electric) + // Return a failing decoder for any other strings + _ -> decode.failure(Fire, "PocketMonsterType") + } + } + + decode.run(dynamic.from("water"), decoder) + |> should.be_ok + |> should.equal(Water) + + decode.run(dynamic.from("wobble"), decoder) + |> should.be_error + |> should.equal([DecodeError("PocketMonsterType", "String", [])]) +} + +pub type PocketMonsterPerson { + Trainer(name: String, badge_count: Int) + GymLeader(name: String, speciality: String) +} + +pub fn documentation_variants_example_test() { + let trainer_decoder = { + use name <- decode.field("name", decode.string) + use badge_count <- decode.field("badge-count", decode.int) + decode.success(Trainer(name, badge_count)) + } + + let gym_leader_decoder = { + use name <- decode.field("name", decode.string) + use speciality <- decode.field("speciality", decode.string) + decode.success(GymLeader(name, speciality)) + } + + let decoder = { + use tag <- decode.field("type", decode.string) + case tag { + "gym-leader" -> gym_leader_decoder + _ -> trainer_decoder + } + } + + // Trainer + dynamic.from( + dict.from_list([ + #("type", dynamic.from("trainer")), + #("name", dynamic.from("Ash")), + #("badge-count", dynamic.from(8)), + ]), + ) + |> decode.run(decoder) + |> should.be_ok + |> should.equal(Trainer("Ash", 8)) + + // Gym leader + dynamic.from( + dict.from_list([ + #("type", dynamic.from("gym-leader")), + #("name", dynamic.from("Brock")), + #("speciality", dynamic.from("Rock")), + ]), + ) + |> decode.run(decoder) + |> should.be_ok + |> should.equal(GymLeader("Brock", "Rock")) + + // Error + dynamic.from( + dict.from_list([ + #("type", dynamic.from("gym-leader")), + #("name", dynamic.from("Brock")), + ]), + ) + |> decode.run(decoder) + |> should.be_error + |> should.equal([ + DecodeError(expected: "Field", found: "Nothing", path: ["speciality"]), + ]) +} + +pub fn new_primitive_decoder_string_ok_test() { + dynamic.from("Hello!") + |> decode.run(decode.new_primitive_decoder(dynamic.string, "")) + |> should.be_ok + |> should.equal("Hello!") +} + +pub fn new_primitive_decoder_string_error_test() { + dynamic.from(123) + |> decode.run(decode.new_primitive_decoder(dynamic.string, "")) + |> should.be_error + |> should.equal([DecodeError("String", "Int", [])]) +} + +pub fn new_primitive_decoder_float_ok_test() { + dynamic.from(12.4) + |> decode.run(decode.new_primitive_decoder(dynamic.float, 0.0)) + |> should.be_ok + |> should.equal(12.4) +} + +pub fn new_primitive_decoder_float_error_test() { + dynamic.from("blah") + |> decode.run(decode.new_primitive_decoder(dynamic.float, 0.0)) + |> should.be_error + |> should.equal([DecodeError("Float", "String", [])]) +} + +pub type LinkedList { + ListEmpty + ListNonEmpty(element: Int, tail: LinkedList) +} + +pub fn list_decoder() -> decode.Decoder(LinkedList) { + use tag <- decode.field("type", decode.string) + case tag { + "list-non-empty" -> { + use element <- decode.field("element", decode.int) + use tail <- decode.field("tail", list_decoder()) + decode.success(ListNonEmpty(element: element, tail: tail)) + } + _ -> decode.success(ListEmpty) + } +} + +pub fn recursive_data_structure_test() { + dynamic.from( + dict.from_list([ + #("type", dynamic.from("list-non-empty")), + #("element", dynamic.from(1)), + #( + "tail", + dynamic.from( + dict.from_list([ + #("type", dynamic.from("list-non-empty")), + #("element", dynamic.from(2)), + #( + "tail", + dynamic.from( + dict.from_list([#("type", dynamic.from("list-empty"))]), + ), + ), + ]), + ), + ), + ]), + ) + |> decode.run(list_decoder()) + |> should.be_ok + |> should.equal(ListNonEmpty(1, ListNonEmpty(2, ListEmpty))) +} + +pub fn optionally_at_dict_string_ok_test() { + dynamic.from( + dict.from_list([ + #( + "first", + dict.from_list([#("second", dict.from_list([#("third", 1337)]))]), + ), + ]), + ) + |> decode.run(decode.optionally_at( + ["first", "second", "third"], + 100, + decode.int, + )) + |> should.be_ok + |> should.equal(1337) +} + +pub fn optionally_at_dict_int_ok_test() { + dynamic.from( + dict.from_list([ + #(10, dict.from_list([#(20, dict.from_list([#(30, 1337)]))])), + ]), + ) + |> decode.run(decode.optionally_at([10, 20, 30], 123, decode.int)) + |> should.be_ok + |> should.equal(1337) +} + +pub fn optionally_at_tuple_int_ok_test() { + dynamic.from(#("x", #("a", "b", "c"), "z")) + |> decode.run(decode.optionally_at([1, 0], "something", decode.string)) + |> should.be_ok + |> should.equal("a") +} + +pub fn optionally_at_wrong_inner_error_test() { + dynamic.from( + dict.from_list([ + #( + "first", + dict.from_list([#("second", dict.from_list([#("third", 1337)]))]), + ), + ]), + ) + |> decode.run(decode.optionally_at( + ["first", "second", "third"], + "default", + decode.string, + )) + |> should.be_error + |> should.equal([DecodeError("String", "Int", ["first", "second", "third"])]) +} + +pub fn optionally_at_no_path_error_test() { + dynamic.from(dict.from_list([#("first", dict.from_list([#("third", 1337)]))])) + |> decode.run(decode.optionally_at( + ["first", "second", "third"], + 100, + decode.int, + )) + |> should.be_ok + |> should.equal(100) +} diff --git a/test/gleam/should.gleam b/test/gleam/should.gleam index d63f6b7..b11770a 100644 --- a/test/gleam/should.gleam +++ b/test/gleam/should.gleam @@ -47,7 +47,7 @@ pub fn be_ok(a: Result(a, e)) -> a { pub fn be_error(a) { case a { - Error(_) -> Nil + Error(e) -> e _ -> panic as { string.concat(["\n", string.inspect(a), "\nshould be error"]) } } |