├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── README.md ├── gleam.toml ├── rebar.config ├── rebar.lock ├── src ├── gleam │ └── jsone.gleam └── gleam_jsone.app.src └── test ├── gleam └── jsone_test.gleam └── test_helpers.erl /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test, ensure formatted, and verify README examples 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | ci: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | gleam: [0.12.1, 0.13.2, 0.14.0] 11 | steps: 12 | - uses: actions/checkout@v2.0.0 13 | 14 | - uses: gleam-lang/setup-erlang@v1.1.2 15 | with: 16 | otp-version: 23.2 17 | - uses: gleam-lang/setup-gleam@v1.0.2 18 | with: 19 | gleam-version: ${{ matrix.gleam }} 20 | - run: rebar3 install_deps 21 | - name: Run Gleam tests 22 | run: rebar3 eunit 23 | - run: gleam format --check src test 24 | 25 | - uses: actions/setup-node@v1 26 | with: 27 | node-version: '14.x' 28 | - run: npm install -g codedown 29 | - name: Verify that Gleam code in README compiles 30 | run: | 31 | cat README.md | codedown rust > src/readme.gleam 32 | gleam build 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.iml 3 | *.o 4 | *.plt 5 | *.swo 6 | *.swp 7 | *~ 8 | .erlang.cookie 9 | .eunit 10 | .idea 11 | .rebar 12 | .rebar3 13 | _* 14 | _build 15 | ebin 16 | erl_crash.dump 17 | gen 18 | log 19 | logs 20 | rebar3.crashdump 21 | src/readme.gleam 22 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | gleam 0.14.0 2 | erlang 23.2 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on 6 | [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project 7 | adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 8 | 9 | ## Unreleased 10 | 11 | ## v0.5.0 - 2021-02-27 12 | 13 | ### Added 14 | 15 | - Rename and expose `to_dynamic` function. 16 | 17 | ### Changed 18 | 19 | - Relax dependency constraints. 20 | 21 | ## v0.4.0 - 2021-02-22 22 | 23 | ### Added 24 | 25 | - Matrix strategy to CI to test multiple Gleam language versions. 26 | 27 | ### Changed 28 | 29 | - Gleam version to 0.14.0, `gleam_stdlib` version to 0.14.0. 30 | 31 | ## v0.3.1 - 2020-09-07 32 | 33 | ### Fixed 34 | 35 | - Properly update `gleam_stdlib` (to version 0.11.0) and `gleam_decode` (to 36 | version 1.5.1). 37 | 38 | ## v0.3.0 - 2020-09-06 39 | 40 | ### Changed 41 | 42 | - Gleam version to 0.11.2, `gleam_stdlib` version to 0.11.0, and `gleam_decode` 43 | version to 1.5.0. 44 | 45 | ## v0.2.0 - 2020-05-11 46 | 47 | ### Changed 48 | 49 | - Gleam version to 0.8.0 and `gleam_stdlib` version to 0.8.0. 50 | 51 | ## v0.1.0 - 2020-04-15 52 | 53 | - Initial release! 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright {{copyright_year}}, {{author_name}} <{{author_email}}>. 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | 192 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :warning: Use [`gleam-lang/json`](https://github.com/gleam-lang/json) instead! :warning: 2 | 3 | # gleam_jsone 4 | 5 | [![Hex.pm](https://img.shields.io/hexpm/v/gleam_jsone)](https://hex.pm/packages/gleam_jsone) [![HexDocs.pm](https://img.shields.io/badge/hex-docs-ff69b4)](https://hexdocs.pm/gleam_jsone/) 6 | 7 | Gleam bindings to the fast, simple JSON decoding/encoding Erlang library, 8 | [`jsone`](https://github.com/sile/jsone). 9 | 10 | This library will always aim to track the latest `jsone` release. You can find 11 | the latest `jsone` documentation [here](https://hexdocs.pm/jsone/). 12 | 13 | Although fully functional, **please note** that this library currently has very 14 | unhelpful error messages. If you are using it and would like better, please open 15 | an issue (or, of course, a PR)! 16 | 17 | Once we have nice error messages, we'll be ready for v1.0.0! 18 | 19 | ## Installation 20 | 21 | Add `gleam_jsone` to the deps section of your `rebar.config` file. 22 | 23 | ```erlang 24 | {deps, [ 25 | {gleam_jsone, "0.5.0"} 26 | ]}. 27 | ``` 28 | 29 | ## Examples 30 | 31 | This library allows you to decode JSON `String`s into Erlang `Dynamic` data. In 32 | order to turn that into Gleam data, you'll either need to use the functions for 33 | `Dynamic` data in the Gleam standard library, or a library like 34 | [`gleam_decode`](https://github.com/rjdellecese/gleam_decode). The examples 35 | below use the standard library functions, but you can check out the test suite 36 | for examples that use `gleam_decode`. 37 | 38 | ```rust 39 | import gleam/dynamic 40 | import gleam/jsone 41 | import gleam/result 42 | 43 | pub fn decode_json_int() -> Result(Int, String) { 44 | "1" 45 | |> jsone.decode 46 | |> result.then(fn(json_dynamic) { 47 | dynamic.int(json_dynamic) 48 | }) //=> Ok(1) 49 | }; 50 | 51 | pub fn decode_json_object_field() -> Result(Bool, String) { 52 | "{ \"boolean\": true }" 53 | |> jsone.decode 54 | |> result.then(fn(json_dynamic) { 55 | dynamic.field(json_dynamic, "boolean") 56 | }) 57 | |> result.then(fn(boolean_field_dynamic) { 58 | dynamic.bool(boolean_field_dynamic) 59 | }) //=> Ok(true) 60 | }; 61 | 62 | pub type JsonObject { 63 | JsonObject( 64 | boolean: Bool, 65 | int: Int 66 | ) 67 | }; 68 | 69 | pub fn decode_json_object() -> Result(JsonObject, String) { 70 | let dynamic_object_result = 71 | " 72 | { 73 | \"boolean\": true, 74 | \"int\": 1 75 | } 76 | " 77 | |> jsone.decode 78 | 79 | let boolean_value_result = 80 | dynamic_object_result 81 | |> result.then(fn(dynamic_object) { 82 | dynamic_object 83 | |> dynamic.field("boolean") 84 | |> result.then(fn(dynamic_boolean) { 85 | dynamic.bool(dynamic_boolean) 86 | }) 87 | }) 88 | 89 | let int_value_result = 90 | dynamic_object_result 91 | |> result.then(fn(dynamic_object) { 92 | dynamic_object 93 | |> dynamic.field("int") 94 | |> result.then(fn(dynamic_int) { 95 | dynamic.int(dynamic_int) 96 | }) 97 | }) 98 | 99 | case boolean_value_result, int_value_result { 100 | Ok(boolean_value), Ok(int_value) -> Ok(JsonObject(boolean_value, int_value)) 101 | _, _ -> Error("Couldn't decode JSON into JsonObject.") 102 | } //=> Ok(JsonObject(true, 1)) 103 | }; 104 | 105 | pub fn decode_bad_json() -> Result(Int, String) { 106 | "<1x.1" 107 | |> jsone.decode 108 | |> result.then(fn(json_dynamic) { 109 | dynamic.int(json_dynamic) 110 | }) //=> Error("Invalid JSON value") 111 | } 112 | ``` 113 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "gleam_jsone" 2 | repository = { type = "github", user = "rjdellecese", repo = "gleam_jsone" } 3 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | {erl_opts, [debug_info]}. 2 | {src_dirs, ["src", "gen/src"]}. 3 | 4 | {profiles, [ 5 | {test, [{src_dirs, ["src", "test", "gen/src", "gen/test"]}]} 6 | ]}. 7 | 8 | {plugins, [rebar3_hex]}. 9 | {project_plugins, [rebar_gleam]}. 10 | 11 | {deps, [ 12 | {gleam_decode, "~> 1.5.0"}, 13 | {gleam_stdlib, "~> 0.11"}, 14 | {jsone, "~> 1.5.0"} 15 | ]}. 16 | -------------------------------------------------------------------------------- /rebar.lock: -------------------------------------------------------------------------------- 1 | {"1.2.0", 2 | [{<<"gleam_decode">>,{pkg,<<"gleam_decode">>,<<"1.5.1">>},0}, 3 | {<<"gleam_stdlib">>,{pkg,<<"gleam_stdlib">>,<<"0.14.0">>},0}, 4 | {<<"jsone">>,{pkg,<<"jsone">>,<<"1.5.2">>},0}]}. 5 | [ 6 | {pkg_hash,[ 7 | {<<"gleam_decode">>, <<"5DACACDB20211CF202736BF4221FE7063D4BD09C4E57B13E616176AC3BCEB09F">>}, 8 | {<<"gleam_stdlib">>, <<"765D90AC06F97D7A8E8F7AC4BEDA373879D98C78A27FD8DEACC98AA34DA2DDCE">>}, 9 | {<<"jsone">>, <<"87ADEA283C9CF24767B4DEED44602989A5331156DF5D60A2660E9C9114D54046">>}]}, 10 | {pkg_hash_ext,[ 11 | {<<"gleam_decode">>, <<"4C20FC3D1248F1913F8C20B675DDE8DE4D43A23FEA3E67F51F1D170DCA3A44BD">>}, 12 | {<<"gleam_stdlib">>, <<"9107F6A859CB96945AD9A099085DB028CA2BEBB3C8EA42EEC227B51C614CC2E0">>}, 13 | {<<"jsone">>, <<"170C171CE7F6DD70C858065154A3305B8564833C6DCCA17E10B676CA31EA976F">>}]} 14 | ]. 15 | -------------------------------------------------------------------------------- /src/gleam/jsone.gleam: -------------------------------------------------------------------------------- 1 | import decode.{Decoder, decode_dynamic} as dynamic_decode 2 | import gleam/atom as atom_mod 3 | import gleam/dynamic.{Dynamic} 4 | import gleam/string 5 | import gleam/map 6 | import gleam/pair 7 | import gleam/list as list_mod 8 | 9 | /// The option for determining whether to return the first or last key/value 10 | /// when there exist duplicates. 11 | pub type DuplicateMapKeys { 12 | First 13 | Last 14 | } 15 | 16 | // DECODING 17 | /// The available decoding options. The descriptions below are lifted or 18 | /// adapted from the [jsone docs](https://hexdocs.pm/jsone/). 19 | /// 20 | /// 21 | /// ### `allow_ctrl_chars` 22 | /// 23 | /// If the value is `True`, strings which contain unescaped control characters 24 | /// will be regarded as a legal JSON string. 25 | /// 26 | /// 27 | /// ### `reject_invalid_utf8` 28 | /// 29 | /// Rejects JSON strings which contain invalid UTF-8 byte sequences. 30 | /// 31 | /// 32 | /// ### `duplicate_map_keys` 33 | /// 34 | /// [IETF RFC 4627](https://www.ietf.org/rfc/rfc4627.txt) says that keys SHOULD 35 | /// be unique, but they don't have to be. Most JSON parsers will either give 36 | /// you the value of the first, or last duplicate property encountered. 37 | /// 38 | /// If the value of this option is `First`, the first duplicate key/value is 39 | /// returned. If it is `Last`, the last is instead. 40 | pub type Options { 41 | Options( 42 | allow_ctrl_chars: Bool, 43 | reject_invalid_utf8: Bool, 44 | duplicate_map_keys: DuplicateMapKeys, 45 | ) 46 | } 47 | 48 | /// The default options used by the `decode` function. They are: 49 | /// 50 | /// ``` 51 | /// Options( 52 | /// allow_ctrl_chars: False, 53 | /// reject_invalid_utf8: False, 54 | /// duplicate_map_keys: First, 55 | /// ) 56 | /// ``` 57 | pub fn default_options() -> Options { 58 | Options( 59 | allow_ctrl_chars: False, 60 | reject_invalid_utf8: False, 61 | duplicate_map_keys: First, 62 | ) 63 | } 64 | 65 | /// Transforms the jsone options from Gleam into their proper Erlang format. 66 | fn transform_options(options: Options) -> Dynamic { 67 | let Options( 68 | duplicate_map_keys: duplicate_map_keys, 69 | reject_invalid_utf8: reject_invalid_utf8, 70 | allow_ctrl_chars: allow_ctrl_chars, 71 | ) = options 72 | 73 | let duplicate_map_keys_dynamic = 74 | tuple( 75 | atom_mod.create_from_string("duplicate_map_keys"), 76 | case duplicate_map_keys { 77 | First -> atom_mod.create_from_string("first") 78 | Last -> atom_mod.create_from_string("last") 79 | }, 80 | ) 81 | |> dynamic.from 82 | 83 | let allow_ctrl_chars_dynamic = 84 | tuple(atom_mod.create_from_string("allow_ctrl_chars"), allow_ctrl_chars) 85 | |> dynamic.from 86 | 87 | let reject_invalid_utf8_dynamic = 88 | atom_mod.create_from_string("reject_invalid_utf8") 89 | |> dynamic.from 90 | 91 | let maybe_prepend_reject_invalid_utf8_dynamic = fn(options: List(Dynamic)) { 92 | case reject_invalid_utf8 { 93 | True -> [reject_invalid_utf8_dynamic, ..options] 94 | False -> options 95 | } 96 | } 97 | 98 | [duplicate_map_keys_dynamic, allow_ctrl_chars_dynamic] 99 | |> maybe_prepend_reject_invalid_utf8_dynamic 100 | |> dynamic.from 101 | } 102 | 103 | // PERFORM DECODING 104 | external fn jsone_try_decode(String) -> Dynamic = 105 | "jsone" "try_decode" 106 | 107 | external fn jsone_try_decode_with_options(String, Dynamic) -> Dynamic = 108 | "jsone" "try_decode" 109 | 110 | fn jsone_try_decode_decoder() -> Decoder(Dynamic) { 111 | let ok_decoder = dynamic_decode.element(1, dynamic_decode.dynamic()) 112 | let error_decoder = dynamic_decode.fail("Invalid JSON") 113 | 114 | dynamic_decode.ok_error_tuple(ok_decoder, error_decoder) 115 | } 116 | 117 | // Uses the `default_options`. 118 | pub fn decode(json: String) -> Result(Dynamic, String) { 119 | json 120 | |> jsone_try_decode 121 | |> decode_dynamic(jsone_try_decode_decoder()) 122 | } 123 | 124 | pub fn decode_with_options( 125 | json: String, 126 | options: Options, 127 | ) -> Result(Dynamic, String) { 128 | json 129 | |> jsone_try_decode_with_options(transform_options(options)) 130 | |> decode_dynamic(jsone_try_decode_decoder()) 131 | } 132 | 133 | // ENCODING 134 | /// Represents a JSON value. 135 | pub type JsonValue { 136 | JsonString(String) 137 | JsonNumber(JsonNumber) 138 | JsonArray(List(JsonValue)) 139 | JsonBool(Bool) 140 | JsonNull 141 | JsonObject(List(tuple(String, JsonValue))) 142 | } 143 | 144 | /// A JSON number can be either an `Int` or a `Float`. 145 | pub type JsonNumber { 146 | JsonInt(Int) 147 | JsonFloat(Float) 148 | } 149 | 150 | /// Create a JSON string. 151 | pub fn string(string: String) -> JsonValue { 152 | JsonString(string) 153 | } 154 | 155 | /// Create a JSON int. 156 | pub fn int(int: Int) -> JsonValue { 157 | JsonNumber(JsonInt(int)) 158 | } 159 | 160 | /// Create a JSON float. 161 | pub fn float(float: Float) -> JsonValue { 162 | JsonNumber(JsonFloat(float)) 163 | } 164 | 165 | /// Create an array of JSON values. 166 | pub fn array(list: List(a), encoder: fn(a) -> JsonValue) -> JsonValue { 167 | list 168 | |> list_mod.map(encoder) 169 | |> JsonArray 170 | } 171 | 172 | /// Create a JSON boolean value. 173 | pub fn bool(bool: Bool) -> JsonValue { 174 | JsonBool(bool) 175 | } 176 | 177 | /// Create a JSON null value. 178 | pub fn null() -> JsonValue { 179 | JsonNull 180 | } 181 | 182 | /// Create a JSON object. 183 | pub fn object(object: List(tuple(String, JsonValue))) -> JsonValue { 184 | JsonObject(object) 185 | } 186 | 187 | pub fn to_dynamic(json_value: JsonValue) -> Dynamic { 188 | case json_value { 189 | JsonString(string) -> dynamic.from(string) 190 | JsonNumber(json_number) -> 191 | case json_number { 192 | JsonInt(int) -> dynamic.from(int) 193 | JsonFloat(float) -> dynamic.from(float) 194 | } 195 | JsonArray(list) -> 196 | list 197 | |> list_mod.map(to_dynamic) 198 | |> dynamic.from 199 | JsonNull -> 200 | "null" 201 | |> atom_mod.create_from_string 202 | |> dynamic.from 203 | JsonBool(bool) -> dynamic.from(bool) 204 | JsonObject(object) -> 205 | object 206 | |> list_mod.map(pair.map_second(_, to_dynamic)) 207 | |> map.from_list 208 | |> dynamic.from 209 | } 210 | } 211 | 212 | external fn jsone_try_encode(Dynamic) -> Dynamic = 213 | "jsone" "try_encode" 214 | 215 | fn jsone_try_decoder() -> Decoder(Dynamic) { 216 | let ok_decoder = dynamic_decode.element(1, dynamic_decode.dynamic()) 217 | let error_decoder = dynamic_decode.fail("Invalid JSON value") 218 | 219 | dynamic_decode.ok_error_tuple(ok_decoder, error_decoder) 220 | } 221 | 222 | /// Encode a JSON value as a UTF-8 binary. 223 | pub fn encode(json_value: JsonValue) -> Result(Dynamic, String) { 224 | json_value 225 | |> to_dynamic 226 | |> jsone_try_encode 227 | |> decode_dynamic(jsone_try_decoder()) 228 | } 229 | -------------------------------------------------------------------------------- /src/gleam_jsone.app.src: -------------------------------------------------------------------------------- 1 | {application,gleam_jsone, 2 | [{description,"jsone bindings for the Gleam language"}, 3 | {vsn,"0.5.0"}, 4 | {registered,[]}, 5 | {applications,[kernel,stdlib]}, 6 | {env,[]}, 7 | {modules,[]}, 8 | {licenses,["Apache 2.0"]}, 9 | {links,[{"GitHub","https://github.com/rjdellecese/gleam_jsone"}, 10 | {"Changelog", 11 | "https://github.com/rjdellecese/gleam_jsone/blob/master/CHANGELOG.md"}]}, 12 | {include_files,["gleam.toml","gen/src"]}]}. 13 | -------------------------------------------------------------------------------- /test/gleam/jsone_test.gleam: -------------------------------------------------------------------------------- 1 | import decode.{decode_dynamic} 2 | import gleam/atom.{Atom} 3 | import gleam/should 4 | import gleam/result 5 | import gleam/jsone.{Options} 6 | 7 | // DECODING 8 | fn json_basics() -> String { 9 | " 10 | { 11 | \"array\": [ 12 | 1, 13 | 2, 14 | 3 15 | ], 16 | \"boolean\": true, 17 | \"color\": \"#82b92c\", 18 | \"null\": null, 19 | \"number\": 123, 20 | \"object\": { 21 | \"a\": \"b\", 22 | \"c\": \"d\", 23 | \"e\": \"f\" 24 | }, 25 | \"string\": \"Hello World\" 26 | } 27 | " 28 | } 29 | 30 | type JsonBasicsObject { 31 | JsonBasicsObject(a: String, c: String, e: String) 32 | } 33 | 34 | type JsonBasicsColor { 35 | JsonBasicsColorHex(String) 36 | } 37 | 38 | type JsonBasics { 39 | JsonBasics( 40 | array: List(Int), 41 | boolean: Bool, 42 | color: JsonBasicsColor, 43 | null: Atom, 44 | number: Int, 45 | object: JsonBasicsObject, 46 | string: String, 47 | ) 48 | } 49 | 50 | pub fn decode_test() { 51 | "1" 52 | |> jsone.decode 53 | |> result.then(decode_dynamic(_, decode.int())) 54 | |> should.equal(Ok(1)) 55 | 56 | "<1x.1" 57 | |> jsone.decode 58 | |> should.equal(Error("Invalid JSON")) 59 | 60 | let json_basics_object_decoder = 61 | decode.map3( 62 | JsonBasicsObject, 63 | decode.field("a", decode.string()), 64 | decode.field("c", decode.string()), 65 | decode.field("e", decode.string()), 66 | ) 67 | 68 | let json_basics_decoder = 69 | decode.map7( 70 | JsonBasics, 71 | decode.field("array", decode.list(decode.int())), 72 | decode.field("boolean", decode.bool()), 73 | decode.field("color", decode.map(JsonBasicsColorHex, decode.string())), 74 | decode.field("null", decode.atom()), 75 | decode.field("number", decode.int()), 76 | decode.field("object", json_basics_object_decoder), 77 | decode.field("string", decode.string()), 78 | ) 79 | 80 | json_basics() 81 | |> jsone.decode 82 | |> result.then(decode_dynamic(_, json_basics_decoder)) 83 | |> should.equal(Ok(JsonBasics( 84 | array: [1, 2, 3], 85 | boolean: True, 86 | color: JsonBasicsColorHex("#82b92c"), 87 | null: atom.create_from_string("null"), 88 | number: 123, 89 | object: JsonBasicsObject(a: "b", c: "d", e: "f"), 90 | string: "Hello World", 91 | ))) 92 | } 93 | 94 | pub fn duplicate_map_keys_test() { 95 | let json_with_duplicate_keys = 96 | " 97 | { 98 | \"duplicate\": \"first\", 99 | \"duplicate\": \"last\" 100 | } 101 | " 102 | 103 | let duplicate_decoder = decode.field("duplicate", decode.string()) 104 | let duplicate_keys_first_options = 105 | Options( 106 | duplicate_map_keys: jsone.First, 107 | reject_invalid_utf8: False, 108 | allow_ctrl_chars: False, 109 | ) 110 | 111 | json_with_duplicate_keys 112 | |> jsone.decode_with_options(duplicate_keys_first_options) 113 | |> result.then(decode_dynamic(_, duplicate_decoder)) 114 | |> should.equal(Ok("first")) 115 | 116 | let duplicate_keys_last_options = 117 | Options( 118 | duplicate_map_keys: jsone.Last, 119 | reject_invalid_utf8: False, 120 | allow_ctrl_chars: False, 121 | ) 122 | 123 | json_with_duplicate_keys 124 | |> jsone.decode_with_options(duplicate_keys_last_options) 125 | |> result.then(decode_dynamic(_, duplicate_decoder)) 126 | |> should.equal(Ok("last")) 127 | } 128 | 129 | external fn string_with_unescaped_newline() -> String = 130 | "test_helpers" "string_with_unescaped_newline" 131 | 132 | external fn string_with_escaped_newline() -> String = 133 | "test_helpers" "string_with_escaped_newline" 134 | 135 | pub fn allow_ctrl_chars_test() { 136 | let allow_ctrl_chars_true_option = 137 | Options( 138 | duplicate_map_keys: jsone.First, 139 | reject_invalid_utf8: False, 140 | allow_ctrl_chars: False, 141 | ) 142 | 143 | string_with_unescaped_newline() 144 | |> jsone.decode_with_options(allow_ctrl_chars_true_option) 145 | |> result.then(decode_dynamic(_, decode.string())) 146 | |> should.be_error 147 | 148 | string_with_escaped_newline() 149 | |> jsone.decode_with_options(allow_ctrl_chars_true_option) 150 | |> result.then(decode_dynamic(_, decode.string())) 151 | |> should.be_ok 152 | 153 | let allow_ctrl_chars_true_option = 154 | Options( 155 | duplicate_map_keys: jsone.First, 156 | reject_invalid_utf8: False, 157 | allow_ctrl_chars: True, 158 | ) 159 | 160 | string_with_unescaped_newline() 161 | |> jsone.decode_with_options(allow_ctrl_chars_true_option) 162 | |> result.then(decode_dynamic(_, decode.string())) 163 | |> should.be_ok 164 | } 165 | 166 | external fn string_with_invalid_utf8() -> String = 167 | "test_helpers" "string_with_invalid_utf8" 168 | 169 | pub fn reject_invalid_utf8_test() { 170 | let reject_invalid_utf8_false_options = 171 | Options( 172 | duplicate_map_keys: jsone.First, 173 | reject_invalid_utf8: False, 174 | allow_ctrl_chars: False, 175 | ) 176 | 177 | string_with_invalid_utf8() 178 | |> jsone.decode_with_options(reject_invalid_utf8_false_options) 179 | |> should.be_ok 180 | 181 | let reject_invalid_utf8_true_options = 182 | Options( 183 | duplicate_map_keys: jsone.First, 184 | reject_invalid_utf8: True, 185 | allow_ctrl_chars: False, 186 | ) 187 | 188 | string_with_invalid_utf8() 189 | |> jsone.decode_with_options(reject_invalid_utf8_true_options) 190 | |> result.then(decode_dynamic(_, decode.string())) 191 | |> should.be_error 192 | } 193 | 194 | // ENCODING 195 | pub fn string_test() { 196 | "string" 197 | |> jsone.string 198 | |> jsone.encode 199 | |> result.then(decode_dynamic(_, decode.string())) 200 | |> should.equal(Ok("\"string\"")) 201 | } 202 | 203 | pub fn int_test() { 204 | 1 205 | |> jsone.int 206 | |> jsone.encode 207 | |> result.then(decode_dynamic(_, decode.string())) 208 | |> should.equal(Ok("1")) 209 | } 210 | 211 | pub fn float_test() { 212 | 1.23 213 | |> jsone.float 214 | |> jsone.encode 215 | |> result.then(decode_dynamic(_, decode.string())) 216 | |> should.equal(Ok("1.22999999999999998224e+00")) 217 | } 218 | 219 | pub fn list_test() { 220 | [1, 2, 3] 221 | |> jsone.array(jsone.int) 222 | |> jsone.encode 223 | |> result.then(decode_dynamic(_, decode.string())) 224 | |> should.equal(Ok("[1,2,3]")) 225 | } 226 | 227 | pub fn null_test() { 228 | jsone.null() 229 | |> jsone.encode 230 | |> result.then(decode_dynamic(_, decode.string())) 231 | |> should.equal(Ok("null")) 232 | } 233 | 234 | pub fn bool_test() { 235 | True 236 | |> jsone.bool 237 | |> jsone.encode 238 | |> result.then(decode_dynamic(_, decode.string())) 239 | |> should.equal(Ok("true")) 240 | } 241 | 242 | pub fn object_test() { 243 | jsone.object([ 244 | tuple("int_field", jsone.int(1)), 245 | tuple("string_field", jsone.string("string")), 246 | ]) 247 | |> jsone.encode 248 | |> result.then(decode_dynamic(_, decode.string())) 249 | |> should.equal(Ok("{\"int_field\":1,\"string_field\":\"string\"}")) 250 | } 251 | -------------------------------------------------------------------------------- /test/test_helpers.erl: -------------------------------------------------------------------------------- 1 | -module(test_helpers). 2 | 3 | -export([string_with_unescaped_newline/0, string_with_escaped_newline/0, string_with_invalid_utf8/0]). 4 | 5 | string_with_unescaped_newline() -> 6 | <<$", "\n", $">>. 7 | 8 | string_with_escaped_newline() -> 9 | <<$", "\\", "n", $">>. 10 | 11 | string_with_invalid_utf8() -> 12 | <<34,190,72,94,90,253,121,94,71,73,68,91,122,211,253,32,94,86,67,163,253,230,34>>. 13 | --------------------------------------------------------------------------------