diff --git a/.github/workflows/makefile.yml b/.github/workflows/makefile.yml index 28fbaad..d701864 100644 --- a/.github/workflows/makefile.yml +++ b/.github/workflows/makefile.yml @@ -24,21 +24,19 @@ jobs: curl https://wasmtime.dev/install.sh -sSf | bash echo "$HOME/.wasmtime/bin" >> $GITHUB_PATH - - name: Install mocv - run: | npm --yes -g i mocv - name: Select mocv version - run: mocv use 0.10.0 + run: mocv use 0.10.3 - name: install mops run: | - npm --yes -g i ic-mops + npm --yes -g i ic-mops@0.34.3 mops i mops sources - - name: Detect Warnings - run: make no-warn + # - name: Detect Warnings + # run: make no-warn - name: Run Tests run: mops test diff --git a/benchmarks/serde.bench.mo b/benchmarks/serde.bench.mo index d97a514..37911e9 100644 --- a/benchmarks/serde.bench.mo +++ b/benchmarks/serde.bench.mo @@ -30,13 +30,96 @@ actor { public query func cycles() : async Nat { Cycles.balance()}; - public query func deserialize(n: Nat) : async (Nat64, Nat, Nat, Nat) { + type Benchmark = { + calls: Nat64; + heap: Nat; + memory: Nat; + cycles: Nat; + }; + + func benchmark(fn: () -> ()): Benchmark { let init_cycles = Cycles.balance(); let init_heap = Prim.rts_heap_size(); let init_memory = Prim.rts_memory_size(); - let calls = IC.countInstructions( + let calls = IC.countInstructions(fn); + + { + calls; + heap = Prim.rts_heap_size() - init_heap; + memory = Prim.rts_memory_size() - init_memory; + cycles = init_cycles - Cycles.balance(); + } + }; + + public query func serialize(n: Nat): async Benchmark { + benchmark( + func(){ + let admin_record : Record = { + group = "admins"; + users = ?[{ + name = "John"; + age = 32; + permission = #admin; + }]; + }; + + let user_record : Record = { + group = "users"; + users = ?[{ + name = "Ali"; + age = 28; + permission = #read_all; + }, { + name = "James"; + age = 40; + permission = #write_all; + }]; + }; + + let empty_record : Record = { + group = "empty"; + users = ?[]; + }; + + let null_record : Record = { + group = "null"; + users = null; + }; + + let base_record : Record = { + group = "base"; + users = ?[{ + name = "Henry"; + age = 32; + permission = #read(["posts", "comments"]); + }, { + name = "Steven"; + age = 32; + permission = #write(["posts", "comments"]); + }]; + }; + + let records : [Record] = [ + null_record, + empty_record, + admin_record, + user_record, + base_record, + ]; + + for (_ in Iter.range(1, n)){ + let blob = to_candid (records); + let #ok(candid) = Candid.decode(blob, [], null); + Debug.print(debug_show (candid)); + }; + } + ) + }; + + public query func deserialize(n: Nat) : async Benchmark { + benchmark( func() { let admin_record_candid : Candid = #Record([ @@ -79,8 +162,6 @@ actor { }; } ); - - (calls, Prim.rts_heap_size() - init_heap, Prim.rts_memory_size() - init_memory, init_cycles - Cycles.balance()) }; }; diff --git a/mops.toml b/mops.toml index 2e922de..11e818e 100644 --- a/mops.toml +++ b/mops.toml @@ -1,15 +1,18 @@ [package] name = "serde" -version = "2.0.4" +version = "2.1.0" description = "A serialisation and deserialisation library for Motoko." repository = "https://github.com/NatLabs/serde" -keywords = [ "json", "candid", "parser", "urlencoded", "serialization" ] +keywords = [ "json", "candid", "cbor", "urlencoded", "serialization" ] [dependencies] -base = "0.10.0" +base = "0.10.3" itertools = "0.1.2" candid = "1.0.2" xtended-numbers = "0.2.1" -json = "https://github.com/aviate-labs/json.mo#v0.2.0" -parser-combinators = "https://github.com/aviate-labs/parser-combinators.mo#v0.1.2" -map = "9.0.0" \ No newline at end of file +json = "https://github.com/aviate-labs/json.mo#v0.2.0@99ae3761c09622a98ae45ebd9ad68f01353df336" +parser-combinators = "https://github.com/aviate-labs/parser-combinators.mo#v0.1.2@6a331bf78e9dcd7623977f06c8e561fd1a8c0103" +cbor = "0.1.3" + +[dev-dependencies] +test = "1.2.0" \ No newline at end of file diff --git a/readme.md b/readme.md index 0c39469..8209cdb 100644 --- a/readme.md +++ b/readme.md @@ -2,11 +2,30 @@ An efficient serialization and deserialization library for Motoko. - +The library contains four modules: +- **Candid** + - `fromText()` - Converts [Candid text](https://internetcomputer.org/docs/current/tutorials/developer-journey/level-2/2.4-intro-candid/#candid-textual-values) to its serialized form. + - `toText()` - Converts serialized candid to its [textual representation](https://internetcomputer.org/docs/current/tutorials/developer-journey/level-2/2.4-intro-candid/#candid-textual-values). + - `encode()` - Converts the [Candid variant](./src/Candid/Types.mo#L6) to a blob. + - `decode()` - Converts a blob to the [Candid variant](./src/Candid/Types.mo#L6). + +- **CBOR** + - `encode()` - Converts serialized candid to CBOR. + - `decode()` - Converts CBOR to a serialized candid. + +- **JSON** + - `fromText()` - Converts JSON text to serialized candid. + - `toText()` - Converts serialized candid to JSON text. + +- **URL-Encoded Pairs** + - `fromText()` - Converts URL-encoded text to serialized candid. + - `toText()` - Converts serialized candid to URL-encoded text. + ## Getting Started -### Installation +### Installation +[![mops](https://oknww-riaaa-aaaam-qaf6a-cai.raw.ic0.app/badge/mops/serde)](https://mops.one/serde) 1. Install [`mops`](https://j4mwm-bqaaa-aaaam-qajbq-cai.ic0.app/#/docs/install). 2. Inside your project directory, run: @@ -18,15 +37,15 @@ mops install serde To start, import the necessary modules: ```motoko -import { JSON; Candid; UrlEncoded } from "mo:serde"; +import { JSON; Candid; CBOR; UrlEncoded } from "mo:serde"; ``` #### JSON -> The API for all serde module is the same, so the following code can be used for converting data between the other modules (Candid and URL-Encoded Pairs). +> The following code can be used for converting data between the other modules (Candid and URL-Encoded Pairs). **Example: JSON to Motoko** -1. **Defining Data Type**: This critical step informs the conversion functions (`from_candid`` and `to_candid`) about how to handle the data. +1. **Defining Data Type**: This critical step informs the conversion functions (`from_candid` and `to_candid`) about how to handle the data. Consider the following JSON data: ```json diff --git a/src/CBOR/lib.mo b/src/CBOR/lib.mo new file mode 100644 index 0000000..e036db0 --- /dev/null +++ b/src/CBOR/lib.mo @@ -0,0 +1,191 @@ +import Buffer "mo:base/Buffer"; +import Blob "mo:base/Blob"; +import Int8 "mo:base/Int8"; +import Int16 "mo:base/Int16"; +import Int32 "mo:base/Int32"; +import Int64 "mo:base/Int64"; +import Nat64 "mo:base/Nat64"; +import Result "mo:base/Result"; + +import CBOR_Value "mo:cbor/Value"; +import CBOR_Encoder "mo:cbor/Encoder"; +import CBOR_Decoder "mo:cbor/Decoder"; +import NatX "mo:xtended-numbers/NatX"; +import IntX "mo:xtended-numbers/IntX"; +import FloatX "mo:xtended-numbers/FloatX"; + +import Candid "../Candid"; +import CandidTypes "../Candid/Types"; + +import Utils "../Utils"; + +module { + public type Candid = CandidTypes.Candid; + type Result = Result.Result; + type CBOR = CBOR_Value.Value; + + /// Converts serialized Candid blob to CBOR blob + public func encode(blob : Blob, keys : [Text], options: ?CandidTypes.Options) : Result { + let decoded_res = Candid.decode(blob, keys, options); + let #ok(candid) = decoded_res else return Utils.send_error(decoded_res); + + let json_res = fromCandid(candid[0]); + let #ok(json) = json_res else return Utils.send_error(json_res); + #ok(json); + }; + + /// Convert a Candid value to CBOR blob + public func fromCandid(candid : Candid) : Result { + let res = transpile_candid_to_cbor(candid); + let #ok(transpiled_cbor) = res else return Utils.send_error(res); + + switch(CBOR_Encoder.encode(transpiled_cbor)){ + case(#ok(encoded_cbor)){ #ok (Blob.fromArray(encoded_cbor))}; + case(#err(#invalidValue(errMsg))){ #err("Invalid value error while encoding CBOR: " # errMsg) }; + }; + }; + + func transpile_candid_to_cbor(candid : Candid) : Result { + let transpiled_cbor : CBOR = switch(candid){ + case (#Empty) #majorType7(#_undefined); + case (#Null) #majorType7(#_null); + case (#Bool(n)) #majorType7(#bool(n)); + case (#Float(n)) #majorType7(#float(FloatX.fromFloat(n, #f64))); + + case (#Nat8(n)) #majorType7(#integer(n)); + case (#Nat16(n)) #majorType0(NatX.from16To64(n)); + case (#Nat32(n)) #majorType0(NatX.from32To64(n)); + case (#Nat64(n)) #majorType0(n); + case (#Nat(n)) #majorType0(Nat64.fromNat(n)); + + case (#Int8(n)) #majorType1(Int8.toInt(n)); + case (#Int16(n)) #majorType1(Int16.toInt(n)); + case (#Int32(n)) #majorType1(Int32.toInt(n)); + case (#Int64(n)) #majorType1(Int64.toInt(n)); + case (#Int(n)) #majorType1(n); + + case (#Blob(blob)) #majorType2(Blob.toArray(blob)); + case (#Text(n)) #majorType3(n); + case (#Array(arr)) { + let buffer = Buffer.Buffer(arr.size()); + + for (item in arr.vals()){ + let res = transpile_candid_to_cbor(item); + let #ok(cbor_val) = res else return Utils.send_error(res); + buffer.add(cbor_val); + }; + + #majorType4(Buffer.toArray(buffer)); + }; + case (#Record(records)) { + let newRecords = Buffer.Buffer<(CBOR, CBOR)>(records.size()); + + for ((key, val) in records.vals()){ + let res = transpile_candid_to_cbor(val); + let #ok(cbor_val) = res else return Utils.send_error(res); + newRecords.add((#majorType3(key), cbor_val)); + }; + + #majorType5(Buffer.toArray(newRecords)); + }; + + // Candid can make variables optional, when it is decoded using + // `from_candid` if its specified in the type defination + // This features allow us to handle optional values when decoding CBOR + // + // check out "CBOR Tests.options" in the tests folder to see how this in action + case (#Option(option)) { + let res = transpile_candid_to_cbor(option); + let #ok(cbor_val) = res else return Utils.send_error(res); + cbor_val + }; + + case (#Variant(_)) { + return #err("#Variant(_) is not supported in this implementation of CBOR"); + }; + + case (#Principal(_)){ + return #err("#Principal(_) is not supported in this implementation of CBOR"); + }; + }; + + // add self-describe tag + let tagged_cbor = #majorType6({ tag = 55799 : Nat64; value = transpiled_cbor; }); + + #ok(tagged_cbor); + }; + + public func decode(blob: Blob, options: ?CandidTypes.Options): Result { + let candid_res = toCandid(blob); + let #ok(candid) = candid_res else return Utils.send_error(candid_res); + Candid.encodeOne(candid, options); + }; + + public func toCandid(blob: Blob): Result { + let cbor_res = CBOR_Decoder.decode(blob); + + let candid_res = switch (cbor_res) { + case (#ok(cbor)) transpile_cbor_to_candid(cbor); + case (#err(cbor_error)) { + switch(cbor_error){ + case (#unexpectedBreak){ return #err("Error decoding CBOR: Unexpected break") }; + case (#unexpectedEndOfBytes) { return #err("Error decoding CBOR: Unexpected end of bytes") }; + case (#invalid(errMsg)) { return #err("Invalid CBOR: " # errMsg) }; + }; + }; + }; + + let #ok(candid) = candid_res else return Utils.send_error(candid_res); + #ok(candid); + }; + + public func transpile_cbor_to_candid(cbor: CBOR) : Result{ + let transpiled_candid = switch(cbor){ + case (#majorType0(n)) #Nat(Nat64.toNat(n)); + case (#majorType1(n)) #Int(n); + case (#majorType2(n)) #Blob(Blob.fromArray(n)); + case (#majorType3(n)) #Text(n); + case (#majorType4(arr)) { + let buffer = Buffer.Buffer(arr.size()); + for (item in arr.vals()){ + let res = transpile_cbor_to_candid(item); + let #ok(candid_val) = res else return Utils.send_error(res); + buffer.add(candid_val); + }; + #Array(Buffer.toArray(buffer)); + }; + case (#majorType5(records)) { + let buffer = Buffer.Buffer<(Text, Candid)>(records.size()); + for ((cbor_text, val) in records.vals()){ + let #majorType3(key) = cbor_text else return #err("Error decoding CBOR: Unexpected key type"); + + let res = transpile_cbor_to_candid(val); + let #ok(candid_val) = res else return Utils.send_error(res); + buffer.add((key, candid_val)); + }; + + #Record(Buffer.toArray(buffer)); + }; + case (#majorType7(#_undefined)) #Empty; + case (#majorType7(#_null)) #Null; + case (#majorType7(#bool(n))) #Bool(n); + case (#majorType7(#integer(n))) #Nat8(n); + case (#majorType7(#float(n))) #Float(FloatX.toFloat(n)); + + case (#majorType7(#_break)) { + return #err("Error decoding CBOR: #_break is not supported"); + }; + case (#majorType6(tagged_cbor)) { + if (tagged_cbor.tag == 55799){ + let transpiled_candid_res = transpile_cbor_to_candid(tagged_cbor.value); + let #ok(transpiled_candid) = transpiled_candid_res else return Utils.send_error(transpiled_candid_res); + transpiled_candid + } else { + return #err("Error decoding CBOR: Tagged values are not supported"); + }; + }; + }; + + #ok(transpiled_candid); + }; +} \ No newline at end of file diff --git a/src/lib.mo b/src/lib.mo index 2258fa5..9842d3b 100644 --- a/src/lib.mo +++ b/src/lib.mo @@ -6,6 +6,8 @@ import CandidTypes "Candid/Types"; import UrlEncodedModule "UrlEncoded"; import JsonModule "JSON"; import CandidModule "Candid"; +import CborModule "CBOR"; + import Utils "Utils"; module { @@ -13,10 +15,11 @@ module { public type Options = CandidTypes.Options; public type Candid = CandidTypes.Candid; - public let Candid = CandidModule; + public let JSON = JsonModule; public let URLEncoded = UrlEncodedModule; + public let CBOR = CborModule; public let concatKeys = Utils.concatKeys; } \ No newline at end of file diff --git a/tests/CBOR.Test.mo b/tests/CBOR.Test.mo new file mode 100644 index 0000000..3fd0305 --- /dev/null +++ b/tests/CBOR.Test.mo @@ -0,0 +1,110 @@ +// @testmode wasi +import Debug "mo:base/Debug"; +import Iter "mo:base/Iter"; +import { test; suite } "mo:test"; + +import { CBOR } "../src"; + +suite( + "CBOR Test", + func() { + test("options", func() { + let opt_nat_null: ?Nat = null; + let opt_nat : ?Nat = ?123; + let opt_text_null: ?Text = null; + let opt_text : ?Text = ?"hello"; + + let opt_nat_null_candid = to_candid(opt_nat_null); + let opt_nat_candid = to_candid(opt_nat); + let opt_text_null_candid = to_candid(opt_text_null); + let opt_text_candid = to_candid(opt_text); + + let #ok(opt_nat_null_cbor) = CBOR.encode(opt_nat_null_candid, [], null); + let #ok(opt_nat_cbor) = CBOR.encode(opt_nat_candid, [], null); + let #ok(opt_text_null_cbor) = CBOR.encode(opt_text_null_candid, [], null); + let #ok(opt_text_cbor) = CBOR.encode(opt_text_candid, [], null); + + let #ok(opt_nat_null_candid2) = CBOR.decode(opt_nat_null_cbor, null); + let #ok(opt_nat_candid2) = CBOR.decode(opt_nat_cbor, null); + let #ok(opt_text_null_candid2) = CBOR.decode(opt_text_null_cbor, null); + let #ok(opt_text_candid2) = CBOR.decode(opt_text_cbor, null); + + assert opt_nat_null_candid != opt_nat_null_candid2; + assert opt_nat_candid != opt_nat_candid2; + assert opt_text_null_candid != opt_text_null_candid2; + assert opt_text_candid != opt_text_candid2; + + let ?opt_nat_null2 : ?(?Nat) = from_candid(opt_nat_null_candid2); + let ?opt_nat2 : ?(?Nat) = from_candid(opt_nat_candid2); + let ?opt_text_null2 : ?(?Text) = from_candid(opt_text_null_candid2); + let ?opt_text2 : ?(?Text) = from_candid(opt_text_candid2); + + assert opt_nat_null2 == opt_nat_null; + assert opt_nat2 == opt_nat; + assert opt_text_null2 == opt_text_null; + assert opt_text2 == opt_text; + + }); + test( + "primitives", + func() { + + let nat : Nat = 123; + let int : Int = -123; + let float : Float = 123.456; + let bool : Bool = true; + let text: Text = "hello"; + let blob: Blob = "\01\02\03"; + let _null: Null = null; + let empty = (); + let list: [Nat] = [1, 2, 3]; + let record = { a = 1; b = 2; }; + + let nat_candid = to_candid(nat); + let int_candid = to_candid(int); + let float_candid = to_candid(float); + let bool_candid = to_candid(bool); + let text_candid = to_candid(text); + let blob_candid = to_candid(blob); + let null_candid = to_candid(_null); + let empty_candid = to_candid(empty); + let list_candid = to_candid(list); + let record_candid = to_candid(record); + + let #ok(nat_cbor) = CBOR.encode(nat_candid, [], null); + let #ok(int_cbor) = CBOR.encode(int_candid, [], null); + let #ok(float_cbor) = CBOR.encode(float_candid, [], null); + let #ok(bool_cbor) = CBOR.encode(bool_candid, [], null); + let #ok(text_cbor) = CBOR.encode(text_candid, [], null); + let #ok(blob_cbor) = CBOR.encode(blob_candid, [], null); + let #ok(null_cbor) = CBOR.encode(null_candid, [], null); + let #ok(empty_cbor) = CBOR.encode(empty_candid, [], null); + let #ok(list_cbor) = CBOR.encode(list_candid, [], null); + let #ok(record_cbor) = CBOR.encode(record_candid, ["a", "b"], null); + + let #ok(nat_candid2) = CBOR.decode(nat_cbor, null); + let #ok(int_candid2) = CBOR.decode(int_cbor, null); + let #ok(float_candid2) = CBOR.decode(float_cbor, null); + let #ok(bool_candid2) = CBOR.decode(bool_cbor, null); + let #ok(text_candid2) = CBOR.decode(text_cbor, null); + let #ok(blob_candid2) = CBOR.decode(blob_cbor, null); + let #ok(null_candid2) = CBOR.decode(null_cbor, null); + let #ok(empty_candid2) = CBOR.decode(empty_cbor, null); + let #ok(list_candid2) = CBOR.decode(list_cbor, null); + let #ok(record_candid2) = CBOR.decode(record_cbor, null); + + assert nat_candid == nat_candid2; + assert int_candid == int_candid2; + assert float_candid == float_candid2; + assert bool_candid == bool_candid2; + assert text_candid == text_candid2; + assert blob_candid == blob_candid2; + assert null_candid == null_candid2; + assert empty_candid == empty_candid2; + assert list_candid == list_candid2; + assert record_candid == record_candid2; + + }, + ); + }, +);