├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── gleam.toml ├── manifest.toml ├── src └── decipher.gleam └── test └── decipher_test.gleam /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: ["v*"] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3.1.0 12 | - uses: erlef/setup-beam@v1.16.0 13 | with: 14 | otp-version: "26.0.2" 15 | rebar3-version: "3" 16 | gleam-version: "1.5.1" 17 | 18 | - run: cargo install tomlq 19 | - run: | 20 | if [ "v$(tomlq version -f gleam.toml)" == "${{ github.ref_name }}" ]; then 21 | exit 0 22 | fi 23 | echo "tag does not match version in gleam.toml, refusing to publish" 24 | exit 1 25 | - run: gleam format --check src test 26 | - run: gleam test 27 | - run: gleam publish -y 28 | env: 29 | HEXPM_USER: ${{ secrets.HEX_USERNAME }} 30 | HEXPM_PASS: ${{ secrets.HEX_PASSWORD }} 31 | 32 | - uses: softprops/action-gh-release@v1 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: erlef/setup-beam@v1 16 | with: 17 | otp-version: "26.0.2" 18 | gleam-version: "1.5.1" 19 | rebar3-version: "3" 20 | # elixir-version: "1.15.4" 21 | - run: gleam deps download 22 | - run: gleam test 23 | - run: gleam format --check src test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | /build 4 | erl_crash.dump 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Hayleigh Thompson 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in the 5 | Software without restriction, including without limitation the rights to use, 6 | copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the 7 | Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # decipher 2 | 3 | [![Package Version](https://img.shields.io/hexpm/v/decipher)](https://hex.pm/packages/decipher) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/decipher/) 5 | 6 | > **decipher**: to discover the meaning of something written badly or in a difficult 7 | > or hidden way: – [Cambridge Dictionary](https://dictionary.cambridge.org/dictionary/english/decipher) 8 | 9 | Decipher is a collection of handy [`gleam/dynamic`](https://hexdocs.pm/gleam_stdlib/gleam/dynamic.html) 10 | decoders to help you deal with awkward JSON APIs, impose additional constraints 11 | on dynamic data, or decode values of more exotic types. 12 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "decipher" 2 | version = "1.2.1" 3 | 4 | description = "A collection of handy gleam/dynamic decoders." 5 | licences = ["MIT"] 6 | repository = { type = "github", user = "hayleigh-dot-dev", repo = "decipher" } 7 | links = [ 8 | { title = "Sponsor", href = "https://github.com/sponsors/hayleigh-dot-dev" }, 9 | ] 10 | 11 | [dependencies] 12 | gleam_stdlib = ">= 0.34.0 and < 2.0.0" 13 | gleam_json = ">= 1.0.1 and < 3.0.0" 14 | birl = ">= 1.6.1 and < 2.0.0" 15 | stoiridh_version = ">= 0.1.0 and < 1.0.0" 16 | 17 | [dev-dependencies] 18 | gleeunit = ">= 1.0.0 and < 2.0.0" 19 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "birl", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "976CFF85D34D50F7775896615A71745FBE0C325E50399787088F941B539A0497" }, 6 | { name = "gleam_json", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB10B0E7BF44282FB25162F1A24C1A025F6B93E777CCF238C4017E4EEF2CDE97" }, 7 | { name = "gleam_stdlib", version = "0.37.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "5398BD6C2ABA17338F676F42F404B9B7BABE1C8DC7380031ACB05BBE1BCF3742" }, 8 | { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, 9 | { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, 10 | { name = "stoiridh_version", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "stoiridh_version", source = "hex", outer_checksum = "298ABEA44DF37764A34C2E9190A84BF2770BC59DD9397C6DC7708040E5A0142B" }, 11 | ] 12 | 13 | [requirements] 14 | birl = { version = ">= 1.6.1 and < 2.0.0" } 15 | gleam_json = { version = ">= 1.0.1 and < 3.0.0" } 16 | gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } 17 | gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 18 | stoiridh_version = { version = ">= 0.1.0 and < 1.0.0" } 19 | -------------------------------------------------------------------------------- /src/decipher.gleam: -------------------------------------------------------------------------------- 1 | // IMPORTS --------------------------------------------------------------------- 2 | 3 | import birl.{type Time} 4 | import gleam/bit_array 5 | import gleam/dict 6 | import gleam/dynamic.{type DecodeError, type Decoder, type Dynamic, DecodeError} 7 | import gleam/float 8 | import gleam/int 9 | import gleam/json 10 | import gleam/list 11 | import gleam/pair 12 | import gleam/result 13 | import gleam/set.{type Set} 14 | import gleam/string 15 | import gleam/uri.{type Uri} 16 | import stoiridh/version.{type Version} 17 | 18 | // PRIMITIVES ------------------------------------------------------------------ 19 | 20 | /// Decode an `Int` as long as it is greater than or equal to zero. 21 | /// 22 | pub fn non_negative_int(dynamic: Dynamic) -> Result(Int, List(DecodeError)) { 23 | use int <- result.try(dynamic.int(dynamic)) 24 | 25 | case int >= 0 { 26 | True -> Ok(int) 27 | False -> 28 | Error([ 29 | DecodeError( 30 | expected: "A non-negative int", 31 | found: int.to_string(int), 32 | path: [], 33 | ), 34 | ]) 35 | } 36 | } 37 | 38 | /// Decode an `Int` that has been converted to a string. Some JSON APIs will 39 | /// send numbers as strings, so this decoder can come in handy more often than 40 | /// you'd think! 41 | /// 42 | pub fn int_string(dynamic: Dynamic) -> Result(Int, List(DecodeError)) { 43 | use string <- result.try(dynamic.string(dynamic)) 44 | 45 | string 46 | |> int.parse 47 | |> result.replace_error([ 48 | DecodeError(expected: "A stringified int", found: string, path: []), 49 | ]) 50 | } 51 | 52 | /// Decode a `Float` that has been converted to a string. Some JSON APIs will 53 | /// send numbers as strings, so this decoder can come in handy more often than 54 | /// you'd think! 55 | /// 56 | pub fn float_string(dynamic: Dynamic) -> Result(Float, List(DecodeError)) { 57 | use string <- result.try(dynamic.string(dynamic)) 58 | 59 | string 60 | |> float.parse 61 | |> result.replace_error([ 62 | DecodeError(expected: "A stringified float", found: string, path: []), 63 | ]) 64 | } 65 | 66 | /// This decoder is capable of decoding both `Int` and `Float` values. If the 67 | /// value is an `Int`, it will be converted to a `Float` automatically. 68 | /// 69 | pub fn number(dynamic: Dynamic) -> Result(Float, List(DecodeError)) { 70 | dynamic.any([ 71 | dynamic.float, 72 | fn(dynamic) { 73 | dynamic.int(dynamic) 74 | |> result.map(int.to_float) 75 | }, 76 | ])(dynamic) 77 | } 78 | 79 | /// Decode numbers that have been converted to strings. This decoder is capable 80 | /// of decoding both `Int` and `Float` values converted to strings. Some JSON 81 | /// APIs will send numbers as strings, so this decoder can come in handy more 82 | /// often than you'd think! 83 | /// 84 | pub fn number_string(dynamic: Dynamic) -> Result(Float, List(DecodeError)) { 85 | dynamic.any([ 86 | float_string, 87 | fn(dynamic) { 88 | int_string(dynamic) 89 | |> result.map(int.to_float) 90 | }, 91 | ])(dynamic) 92 | } 93 | 94 | /// Decode a string that represents a YAML-style boolean value. Any of the following 95 | /// values will be decoded as `True`: 96 | /// 97 | /// - "true" 98 | /// - "True" 99 | /// - "on" 100 | /// - "On" 101 | /// - "yes" 102 | /// - "Yes" 103 | /// 104 | /// Any of the following values will be decoded as `False`: 105 | /// 106 | /// - "false" 107 | /// - "False" 108 | /// - "off" 109 | /// - "Off" 110 | /// - "no" 111 | /// - "No" 112 | /// 113 | /// Anything else will fail to decode. 114 | /// 115 | pub fn bool_string(dynamic: Dynamic) -> Result(Bool, List(DecodeError)) { 116 | enum([ 117 | #("true", True), 118 | #("True", True), 119 | #("on", True), 120 | #("On", True), 121 | #("yes", True), 122 | #("Yes", True), 123 | #("false", False), 124 | #("False", False), 125 | #("off", False), 126 | #("Off", False), 127 | #("no", False), 128 | #("No", False), 129 | ])(dynamic) 130 | } 131 | 132 | /// This decoder will decode a string and then confirm that it is not empty. 133 | /// 134 | pub fn non_empty_string(dynamic: Dynamic) -> Result(String, List(DecodeError)) { 135 | use string <- result.try(dynamic.string(dynamic)) 136 | 137 | case string { 138 | "" -> 139 | Error([ 140 | DecodeError( 141 | expected: "A non-empty string", 142 | found: "An empty string", 143 | path: [], 144 | ), 145 | ]) 146 | _ -> Ok(string) 147 | } 148 | } 149 | 150 | // COLLECTIONS ----------------------------------------------------------------- 151 | 152 | /// Decode a list or [arraylike](#arraylike) into a `Set`. Any duplicate values 153 | /// will be _dropped_. If you want to ensure that there are no duplicates, use 154 | /// the [exact_set](#exact_set) decoder instead. 155 | /// 156 | pub fn set(decoder: Decoder(a)) -> Decoder(Set(a)) { 157 | fn(dynamic: Dynamic) { 158 | dynamic 159 | |> dynamic.any([dynamic.list(decoder), arraylike(decoder)]) 160 | |> result.map(set.from_list) 161 | } 162 | } 163 | 164 | /// Decode a list or [arraylike](#arraylike) into a `Set`. This decoder is slightly 165 | /// slower than the [set](#set) decoder, but it will guarantee that there were no 166 | /// duplicate values in the incoming list. 167 | /// 168 | pub fn exact_set(decoder: Decoder(a)) -> Decoder(Set(a)) { 169 | fn(dynamic: Dynamic) { 170 | use list <- result.try( 171 | dynamic.any([dynamic.list(decoder), arraylike(decoder)])(dynamic), 172 | ) 173 | let length = list.length(list) 174 | let set = set.from_list(list) 175 | 176 | case set.size(set) == length { 177 | True -> Ok(set) 178 | False -> 179 | Error([ 180 | DecodeError( 181 | expected: "A list with no duplicate values", 182 | found: "A list with duplicate values", 183 | path: [], 184 | ), 185 | ]) 186 | } 187 | } 188 | } 189 | 190 | /// Decode a list or [arraylike](#arraylike) with at least one item into a `List`. 191 | /// If the incoming list is empty, decoding will fail. 192 | /// 193 | pub fn non_empty_list(decode: Decoder(a)) -> Decoder(List(a)) { 194 | fn(dynamic: Dynamic) { 195 | use list <- result.try(dynamic.list(decode)(dynamic)) 196 | 197 | case list.is_empty(list) { 198 | True -> 199 | Error([ 200 | DecodeError( 201 | expected: "A non-empty list", 202 | found: "A list with at least 1 item", 203 | path: [], 204 | ), 205 | ]) 206 | False -> Ok(list) 207 | } 208 | } 209 | } 210 | 211 | /// In JavaScript certain objects are said to be "arraylike". These are objects 212 | /// that satisfy the following conditions: 213 | /// 214 | /// - They have a `length` property that is a non-negative integer. 215 | /// - They have a property for each integer index from `0` up to `length - 1`. 216 | /// 217 | /// Operations like `document.querySelectorAll` or `document.getElementsByTagName` 218 | /// return arraylike objects like a [`NodeList`](https://developer.mozilla.org/en-US/docs/Web/API/NodeList). 219 | /// This decoder is capable of decoding such objects into a proper Gleam `List`. 220 | /// 221 | pub fn arraylike(decoder: Decoder(a)) -> Decoder(List(a)) { 222 | fn(dynamic: Dynamic) { 223 | use length <- result.try(dynamic.field("length", dynamic.int)(dynamic)) 224 | 225 | all({ 226 | let list = list.range(0, length - 1) 227 | use index <- list.map(list) 228 | 229 | dynamic.field(int.to_string(index), decoder) 230 | })(dynamic) 231 | } 232 | } 233 | 234 | /// Create a decoder for a list of values from a list of decoders to run. Each 235 | /// decoder will run against the input value, and all must succeed for the decoder 236 | /// to succeed. 237 | /// 238 | /// Errors from each decoder will be collected, which means the entire list is 239 | /// run even if one decoder fails! 240 | /// 241 | pub fn all(decoders: List(Decoder(a))) -> Decoder(List(a)) { 242 | fn(dynamic: Dynamic) { 243 | use list, decoder <- list.fold_right(decoders, Ok([])) 244 | 245 | case list, decoder(dynamic) { 246 | Ok(xs), Ok(x) -> Ok([x, ..xs]) 247 | Ok(_), Error(e) -> Error(e) 248 | Error(e), Ok(_) -> Error(e) 249 | Error(e), Error(x) -> Error(list.append(e, x)) 250 | } 251 | } 252 | } 253 | 254 | // OBJECTS & TUPLES ------------------------------------------------------------ 255 | 256 | /// Decode an object with exactly one field into some value. If additional fields 257 | /// are present, decoding will fail and the additional fields will be reported in 258 | /// the error. 259 | /// 260 | pub fn exact_object1( 261 | constructor: fn(a) -> b, 262 | field1: #(String, Decoder(a)), 263 | ) -> Decoder(b) { 264 | let expected_keys = set.from_list([field1.0]) 265 | 266 | fn(dynamic: Dynamic) { 267 | dynamic 268 | |> dynamic.decode1(constructor, dynamic.field(field1.0, field1.1)) 269 | |> result.then(check_exact_object(_, expected_keys, dynamic)) 270 | } 271 | } 272 | 273 | /// Decode an object with exactly two fields into some value. If additional fields 274 | /// are present, decoding will fail and the additional fields will be reported in 275 | /// the error. 276 | /// 277 | pub fn exact_object2( 278 | constructor: fn(a, b) -> c, 279 | field1: #(String, Decoder(a)), 280 | field2: #(String, Decoder(b)), 281 | ) -> Decoder(c) { 282 | let expected_keys = set.from_list([field1.0]) 283 | 284 | fn(dynamic: Dynamic) { 285 | dynamic 286 | |> dynamic.decode2( 287 | constructor, 288 | dynamic.field(field1.0, field1.1), 289 | dynamic.field(field2.0, field2.1), 290 | ) 291 | |> result.then(check_exact_object(_, expected_keys, dynamic)) 292 | } 293 | } 294 | 295 | /// Decode an object with exactly three fields into some value. If additional fields 296 | /// are present, decoding will fail and the additional fields will be reported in 297 | /// the error. 298 | /// 299 | pub fn exact_object3( 300 | constructor: fn(a, b, c) -> d, 301 | field1: #(String, Decoder(a)), 302 | field2: #(String, Decoder(b)), 303 | field3: #(String, Decoder(c)), 304 | ) -> Decoder(d) { 305 | let expected_keys = set.from_list([field1.0, field2.0, field3.0]) 306 | 307 | fn(dynamic: Dynamic) { 308 | dynamic 309 | |> dynamic.decode3( 310 | constructor, 311 | dynamic.field(field1.0, field1.1), 312 | dynamic.field(field2.0, field2.1), 313 | dynamic.field(field3.0, field3.1), 314 | ) 315 | |> result.then(check_exact_object(_, expected_keys, dynamic)) 316 | } 317 | } 318 | 319 | /// Decode an object with exactly four fields into some value. If additional fields 320 | /// are present, decoding will fail and the additional fields will be reported in 321 | /// the error. 322 | /// 323 | pub fn exact_object4( 324 | constructor: fn(a, b, c, d) -> e, 325 | field1: #(String, Decoder(a)), 326 | field2: #(String, Decoder(b)), 327 | field3: #(String, Decoder(c)), 328 | field4: #(String, Decoder(d)), 329 | ) -> Decoder(e) { 330 | let expected_keys = set.from_list([field1.0, field2.0, field3.0, field4.0]) 331 | 332 | fn(dynamic: Dynamic) { 333 | dynamic 334 | |> dynamic.decode4( 335 | constructor, 336 | dynamic.field(field1.0, field1.1), 337 | dynamic.field(field2.0, field2.1), 338 | dynamic.field(field3.0, field3.1), 339 | dynamic.field(field4.0, field4.1), 340 | ) 341 | |> result.then(check_exact_object(_, expected_keys, dynamic)) 342 | } 343 | } 344 | 345 | /// Decode an object with exactly five fields into some value. If additional fields 346 | /// are present, decoding will fail and the additional fields will be reported in 347 | /// the error. 348 | /// 349 | pub fn exact_object5( 350 | constructor: fn(a, b, c, d, e) -> f, 351 | field1: #(String, Decoder(a)), 352 | field2: #(String, Decoder(b)), 353 | field3: #(String, Decoder(c)), 354 | field4: #(String, Decoder(d)), 355 | field5: #(String, Decoder(e)), 356 | ) -> Decoder(f) { 357 | let expected_keys = 358 | set.from_list([field1.0, field2.0, field3.0, field4.0, field5.0]) 359 | 360 | fn(dynamic: Dynamic) { 361 | dynamic 362 | |> dynamic.decode5( 363 | constructor, 364 | dynamic.field(field1.0, field1.1), 365 | dynamic.field(field2.0, field2.1), 366 | dynamic.field(field3.0, field3.1), 367 | dynamic.field(field4.0, field4.1), 368 | dynamic.field(field5.0, field5.1), 369 | ) 370 | |> result.then(check_exact_object(_, expected_keys, dynamic)) 371 | } 372 | } 373 | 374 | /// Decode an object with exactly six fields into some value. If additional fields 375 | /// are present, decoding will fail and the additional fields will be reported in 376 | /// the error. 377 | /// 378 | pub fn exact_object6( 379 | constructor: fn(a, b, c, d, e, f) -> g, 380 | field1: #(String, Decoder(a)), 381 | field2: #(String, Decoder(b)), 382 | field3: #(String, Decoder(c)), 383 | field4: #(String, Decoder(d)), 384 | field5: #(String, Decoder(e)), 385 | field6: #(String, Decoder(f)), 386 | ) -> Decoder(g) { 387 | let expected_keys = 388 | set.from_list([field1.0, field2.0, field3.0, field4.0, field5.0, field6.0]) 389 | 390 | fn(dynamic: Dynamic) { 391 | dynamic 392 | |> dynamic.decode6( 393 | constructor, 394 | dynamic.field(field1.0, field1.1), 395 | dynamic.field(field2.0, field2.1), 396 | dynamic.field(field3.0, field3.1), 397 | dynamic.field(field4.0, field4.1), 398 | dynamic.field(field5.0, field5.1), 399 | dynamic.field(field6.0, field6.1), 400 | ) 401 | |> result.then(check_exact_object(_, expected_keys, dynamic)) 402 | } 403 | } 404 | 405 | fn check_exact_object( 406 | return: a, 407 | expected: Set(String), 408 | dynamic: Dynamic, 409 | ) -> Result(a, List(DecodeError)) { 410 | use keys <- result.try(keys(dynamic)) 411 | 412 | let found_keys = set.from_list(keys) 413 | let difference = set.to_list(set.difference(found_keys, expected)) 414 | 415 | case list.is_empty(difference) { 416 | True -> Ok(return) 417 | False -> { 418 | let expected_keys = 419 | expected 420 | |> set.to_list 421 | |> string.join(", ") 422 | 423 | let extra_keys = 424 | difference 425 | |> string.join(", ") 426 | 427 | Error([ 428 | DecodeError( 429 | expected: "An object with exactly these keys: " <> expected_keys, 430 | found: "An object with these extra keys: " <> extra_keys, 431 | path: [], 432 | ), 433 | ]) 434 | } 435 | } 436 | } 437 | 438 | // CUSTOM TYPES ---------------------------------------------------------------- 439 | 440 | /// There is no standard way to represent something like Gleam's custom types as 441 | /// JSON or YAML (or most common formats). It's common then to represent them as 442 | /// a _tagged_ or _discriminated_ union where a field is used to signify which 443 | /// variant of the type is being represented. 444 | /// 445 | /// This decoder lets you decode things in this format by first decoding the tag 446 | /// and then selecting the appropriate decoder to run based on that tag. 447 | /// 448 | /// ```gleam 449 | /// import decipher 450 | /// import gleam/dynamic.{type DecodeError, type Decoder, type Dynamic, DecodeError} 451 | /// 452 | /// type Example { 453 | /// Wibble(foo: Int) 454 | /// Wobble(bar: String) 455 | /// } 456 | /// 457 | /// fn example_decoder(dynamic: Dynamic) -> Result(Example, List(DecodeError)) { 458 | /// decipher.tagged_union( 459 | /// dynamic.field("$", dynamic.string), 460 | /// [ 461 | /// #("wibble", dynamic.decode1(Wibble, dynamic.field("foo", dynamic.int))), 462 | /// #("wobble", dynamic.decode1(Wobble, dynamic.field("bar", dynamic.string))), 463 | /// ] 464 | /// )(dynamic) 465 | /// } 466 | /// ``` 467 | /// 468 | pub fn tagged_union( 469 | tag: Decoder(a), 470 | variants: List(#(a, Decoder(b))), 471 | ) -> Decoder(b) { 472 | let switch = dict.from_list(variants) 473 | 474 | fn(dynamic: Dynamic) { 475 | use kind <- result.try(tag(dynamic)) 476 | 477 | case dict.get(switch, kind) { 478 | Ok(decoder) -> decoder(dynamic) 479 | Error(_) -> { 480 | // We're going to report the possible tags as a TS-style union, so 481 | // something like: 482 | // 483 | // "A" | "B" | "C" 484 | // 485 | let tags = 486 | dict.keys(switch) 487 | |> list.map(string.inspect) 488 | |> string.join(" | ") 489 | 490 | // Recover the path from the user's `tag_decoder`. This is kind of hacky 491 | // but honestly if they somehow succeed in decoding `Nil` then what are 492 | // they even playing at. 493 | // 494 | let path = case tag(dynamic.from(Nil)) { 495 | Error([DecodeError(path: path, ..), ..]) -> path 496 | _ -> [] 497 | } 498 | 499 | Error([ 500 | DecodeError(expected: tags, found: string.inspect(kind), path: path), 501 | ]) 502 | } 503 | } 504 | } 505 | } 506 | 507 | /// A simplified version of the [tagged_union](#tagged_union) decoder. First 508 | /// decodes a string, and then attempts to find a corresponding value from a 509 | /// list of variants. 510 | /// 511 | /// This is how the [`bool_string`](#bool_string) decoder is implemented: 512 | /// 513 | /// ```gleam 514 | /// import decipher 515 | /// import gleam/dynamic.{type DecodeError, type Decoder, type Dynamic, DecodeError} 516 | /// 517 | /// pub fn bool_string(dynamic: Dynamic) -> Result(Bool, List(DecodeError)) { 518 | /// decipher.enum([ 519 | /// #("true", True), 520 | /// #("True", True), 521 | /// #("on", True), 522 | /// #("On", True), 523 | /// #("yes", True), 524 | /// #("Yes", True), 525 | /// #("false", False), 526 | /// #("False", False), 527 | /// #("off", False), 528 | /// #("Off", False), 529 | /// #("no", False), 530 | /// #("No", False), 531 | /// ])(dynamic) 532 | /// } 533 | /// ``` 534 | /// 535 | pub fn enum(variants: List(#(String, a))) -> Decoder(a) { 536 | tagged_union( 537 | dynamic.string, 538 | list.map(variants, pair.map_second(_, fn(variant) { fn(_) { Ok(variant) } })), 539 | ) 540 | } 541 | 542 | // EXOTICS --------------------------------------------------------------------- 543 | 544 | /// Decode a string representing an [ISO 8601 datetime](https://en.wikipedia.org/wiki/ISO_8601) 545 | /// as a [`Time`](https://hexdocs.pm/birl/birl.html#Time) value from the birl 546 | /// package. 547 | /// 548 | pub fn iso_8601(dynamic: Dynamic) -> Result(Time, List(DecodeError)) { 549 | use string <- result.try(dynamic.string(dynamic)) 550 | 551 | case birl.parse(string) { 552 | Ok(time) -> Ok(time) 553 | Error(_) -> 554 | Error([ 555 | DecodeError( 556 | expected: "An ISO 8601 date string", 557 | found: string, 558 | path: [], 559 | ), 560 | ]) 561 | } 562 | } 563 | 564 | /// Decode a [Unix timestamp](https://en.wikipedia.org/wiki/Unix_time) as a 565 | /// [`Time`](https://hexdocs.pm/birl/birl.html#Time) value from the birl package. 566 | /// 567 | pub fn unix_timestamp(dynamic: Dynamic) -> Result(Time, List(DecodeError)) { 568 | dynamic 569 | |> dynamic.any([dynamic.int, int_string]) 570 | |> result.map(birl.from_unix) 571 | } 572 | 573 | /// Decode a string representing a [HTTP-date](https://www.rfc-editor.org/rfc/rfc9110#http.date) 574 | /// as a [`Time`](https://hexdocs.pm/birl/birl.html#Time) value from the birl 575 | /// package. 576 | /// 577 | pub fn http_date(dynamic: Dynamic) -> Result(Time, List(DecodeError)) { 578 | use string <- result.try(dynamic.string(dynamic)) 579 | 580 | case birl.from_http(string) { 581 | Ok(time) -> Ok(time) 582 | Error(_) -> 583 | Error([ 584 | DecodeError(expected: "An HTTP date string", found: string, path: []), 585 | ]) 586 | } 587 | } 588 | 589 | /// Decode a string representing a [URI](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier) 590 | /// into a Gleam [`Uri`](https://hexdocs.pm/gleam_stdlib/gleam/uri.html#Uri) value. 591 | /// 592 | pub fn uri(dynamic: Dynamic) -> Result(Uri, List(DecodeError)) { 593 | use string <- result.try(dynamic.string(dynamic)) 594 | 595 | case uri.parse(string) { 596 | Ok(uri) -> Ok(uri) 597 | Error(_) -> 598 | Error([ 599 | DecodeError(expected: "A valid Gleam URI", found: string, path: []), 600 | ]) 601 | } 602 | } 603 | 604 | /// Decode a string representing base16-encoded binary data into a `BitArray`. 605 | /// 606 | pub fn base16(dynamic: Dynamic) -> Result(BitArray, List(DecodeError)) { 607 | use string <- result.try(dynamic.string(dynamic)) 608 | 609 | case bit_array.base16_decode(string) { 610 | Ok(bit_array) -> Ok(bit_array) 611 | Error(_) -> 612 | Error([ 613 | DecodeError( 614 | expected: "A valid base16-encoded string", 615 | found: string, 616 | path: [], 617 | ), 618 | ]) 619 | } 620 | } 621 | 622 | /// Decode a string representing base64-encoded binary data into a `BitArray`. 623 | /// 624 | pub fn base64(dynamic: Dynamic) -> Result(BitArray, List(DecodeError)) { 625 | use string <- result.try(dynamic.string(dynamic)) 626 | 627 | case bit_array.base64_decode(string) { 628 | Ok(bit_array) -> Ok(bit_array) 629 | Error(_) -> 630 | Error([ 631 | DecodeError( 632 | expected: "A valid base64-encoded string", 633 | found: string, 634 | path: [], 635 | ), 636 | ]) 637 | } 638 | } 639 | 640 | /// Decode a string representing a valid semantic version into a [`Version`](https://hexdocs.pm/stoiridh_version/stoiridh/version.html#Version) 641 | /// value from the stoiridh_version package. 642 | /// 643 | pub fn semver(dynamic: Dynamic) -> Result(Version, List(DecodeError)) { 644 | use string <- result.try(dynamic.string(dynamic)) 645 | 646 | case version.parse(string) { 647 | Ok(version) -> Ok(version) 648 | Error(version.InvalidVersion) | Error(version.NegativeValue(_)) -> 649 | Error([ 650 | DecodeError( 651 | expected: "A valid semantic version", 652 | found: string, 653 | path: [], 654 | ), 655 | ]) 656 | Error(version.InvalidMajorVersion) -> 657 | Error([ 658 | DecodeError( 659 | expected: "A valid semantic version", 660 | found: "A semantic version with an invalid major version number", 661 | path: [], 662 | ), 663 | ]) 664 | Error(version.InvalidMinorVersion) -> 665 | Error([ 666 | DecodeError( 667 | expected: "A valid semantic version", 668 | found: "A semantic version with an invalid minor version number", 669 | path: [], 670 | ), 671 | ]) 672 | Error(version.InvalidPatchVersion) -> 673 | Error([ 674 | DecodeError( 675 | expected: "A valid semantic version", 676 | found: "A semantic version with an invalid patch version number", 677 | path: [], 678 | ), 679 | ]) 680 | Error(version.InvalidPrerelease) -> 681 | Error([ 682 | DecodeError( 683 | expected: "A valid semantic version", 684 | found: "A semantic version with an invalid prerelease", 685 | path: [], 686 | ), 687 | ]) 688 | Error(version.InvalidBuildMetadata) -> 689 | Error([ 690 | DecodeError( 691 | expected: "A valid semantic version", 692 | found: "A semantic version with invalid build metadata", 693 | path: [], 694 | ), 695 | ]) 696 | } 697 | } 698 | 699 | /// Decode a string representing url-safe base64-encoded binary data into a 700 | /// `BitArray`. 701 | /// 702 | pub fn base64_url_encoded( 703 | dynamic: Dynamic, 704 | ) -> Result(BitArray, List(DecodeError)) { 705 | use string <- result.try(dynamic.string(dynamic)) 706 | 707 | case bit_array.base64_url_decode(string) { 708 | Ok(bit_array) -> Ok(bit_array) 709 | Error(_) -> 710 | Error([ 711 | DecodeError( 712 | expected: "A valid base64-url-encoded string", 713 | found: string, 714 | path: [], 715 | ), 716 | ]) 717 | } 718 | } 719 | 720 | // UTILITIES ------------------------------------------------------------------- 721 | 722 | /// Run a decoder but only keep the result if it satisfies the given predicate. 723 | /// This is how decoders like [`non_negative_int`](#non_negative_int) can be 724 | /// implemented: 725 | /// 726 | /// ```gleam 727 | /// import decipher 728 | /// import gleam/dynamic.{type DecodeError, type Decoder, type Dynamic, DecodeError} 729 | /// 730 | /// pub fn non_negative_int(dynamic: Dynamic) -> Result(Int, List(DecodeError)) { 731 | /// decipher.when(dynamic.int, is: fn(x) { x >= 0 })(dynamic) 732 | /// } 733 | /// ``` 734 | /// 735 | pub fn when(decoder: Decoder(a), is predicate: fn(a) -> Bool) -> Decoder(a) { 736 | fn(dynamic: Dynamic) { 737 | use value <- result.try(decoder(dynamic)) 738 | 739 | case predicate(value) { 740 | True -> Ok(value) 741 | False -> 742 | Error([ 743 | DecodeError( 744 | expected: "A value that satisfies the predicate", 745 | found: string.inspect(value), 746 | path: [], 747 | ), 748 | ]) 749 | } 750 | } 751 | } 752 | 753 | /// Occasionally you might find yourself in the situation where a JSON string is 754 | /// embedded in the dynamic value you're trying to decode. This decoder lets you 755 | /// extract that JSON and then run the decoder against it. 756 | /// 757 | pub fn json_string(decoder: Decoder(a)) -> Decoder(a) { 758 | fn(dynamic: Dynamic) { 759 | use json <- result.try(dynamic.string(dynamic)) 760 | 761 | case json.decode(json, decoder) { 762 | Ok(a) -> Ok(a) 763 | Error(json.UnexpectedFormat(errors)) -> Error(errors) 764 | Error(_) -> 765 | Error([ 766 | DecodeError( 767 | expected: "A valid JSON-encoded string", 768 | found: json, 769 | path: [], 770 | ), 771 | ]) 772 | } 773 | } 774 | } 775 | 776 | /// Decode just the keys of an object as a list of strings. 777 | /// 778 | pub fn keys(dynamic: Dynamic) -> Result(List(String), List(DecodeError)) { 779 | dynamic 780 | |> dynamic.dict(dynamic.string, dynamic.dynamic) 781 | |> result.map(dict.keys) 782 | } 783 | 784 | /// Decode a value at a given index. This decoder is permissive and will try to 785 | /// decode tuples, objects with string integer keys, and in the worst case will 786 | /// decode a list and index into that. 787 | /// 788 | /// For strict tuple access, use the [`element`](https://hexdocs.pm/gleam_stdlib/gleam/dynamic.html#element) 789 | /// decoder from the standard library. 790 | /// 791 | pub fn index(idx: Int, decoder: Decoder(a)) -> Decoder(a) { 792 | dynamic.any([ 793 | dynamic.element(idx, decoder), 794 | dynamic.field(int.to_string(idx), decoder), 795 | index_list(idx, decoder), 796 | ]) 797 | } 798 | 799 | fn index_list(idx: Int, decoder: Decoder(a)) -> Decoder(a) { 800 | fn(dynamic: Dynamic) { 801 | use list <- result.try(dynamic.list(dynamic.dynamic)(dynamic)) 802 | 803 | case idx >= 0 { 804 | True -> 805 | list 806 | |> list.drop(idx) 807 | |> list.first 808 | |> result.replace_error([ 809 | DecodeError( 810 | expected: "A list with at least" 811 | <> int.to_string(idx + 1) 812 | <> "elements", 813 | found: "A list with" 814 | <> int.to_string(list.length(list)) 815 | <> "elements", 816 | path: [int.to_string(idx)], 817 | ), 818 | ]) 819 | |> result.then(decoder) 820 | False -> 821 | Error([ 822 | DecodeError( 823 | expected: "An 'index' decoder with a non-negative index", 824 | found: int.to_string(idx), 825 | path: [], 826 | ), 827 | ]) 828 | } 829 | } 830 | } 831 | 832 | /// Decode a value at a given key path. This decoder is permissive and will use 833 | /// [`index`](#index) for any numeric string keys where possible. 834 | /// 835 | pub fn at(path: List(String), decoder: Decoder(a)) -> Decoder(a) { 836 | fn(dynamic: Dynamic) { do_at(path, decoder, dynamic) } 837 | } 838 | 839 | fn do_at( 840 | path: List(String), 841 | decoder: Decoder(a), 842 | dynamic: Dynamic, 843 | ) -> Result(a, List(DecodeError)) { 844 | case path { 845 | [] -> decoder(dynamic) 846 | [head, ..rest] -> { 847 | case int.parse(head) { 848 | Ok(idx) -> 849 | dynamic 850 | |> index(idx, dynamic.dynamic) 851 | |> result.then(do_at(rest, decoder, _)) 852 | Error(_) -> 853 | dynamic 854 | |> dynamic.field(head, dynamic.dynamic) 855 | |> result.then(do_at(rest, decoder, _)) 856 | } 857 | } 858 | } 859 | } 860 | -------------------------------------------------------------------------------- /test/decipher_test.gleam: -------------------------------------------------------------------------------- 1 | import decipher 2 | import gleam/dynamic.{DecodeError} 3 | import gleeunit 4 | import gleeunit/should 5 | 6 | pub fn main() { 7 | gleeunit.main() 8 | } 9 | 10 | type Example { 11 | Wibble(foo: Int) 12 | Wobble(bar: String) 13 | } 14 | 15 | pub fn tagged_union_test() { 16 | let decoder = 17 | decipher.tagged_union(dynamic.element(0, dynamic.string), [ 18 | #("wibble", dynamic.decode1(Wibble, dynamic.element(1, dynamic.int))), 19 | #("wobble", dynamic.decode1(Wobble, dynamic.element(1, dynamic.string))), 20 | ]) 21 | 22 | dynamic.from(#("wobble", "bar")) 23 | |> decoder 24 | |> should.be_ok 25 | |> should.equal(Wobble(bar: "bar")) 26 | 27 | dynamic.from(#("jelly")) 28 | |> decoder 29 | |> should.be_error 30 | |> should.equal([DecodeError("\"wibble\" | \"wobble\"", "\"jelly\"", [])]) 31 | } 32 | --------------------------------------------------------------------------------