diff --git a/README.md b/README.md index 0d0ae1e..82e1b65 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# CBOR +# Crystal CBOR [![builds.sr.ht status](https://builds.sr.ht/~arestifo/crystal-cbor.svg)](https://builds.sr.ht/~arestifo/crystal-cbor?) @@ -7,11 +7,67 @@ in Crystal. **WARNING:** This library is still a work in progress. +- [Installation](#installation) +- [Usage](#usage) +- [API](./docs/api.md) +- [Supported Tags](#supported-tags) +- [Limitations](#limitations) +- [Community](#community) +- [Contributing](#contributing) + ## Features +- Full RFC 7049 +- Tested against all examples in the RFC 7049 +- Simple and powerful API inspired by the standard library JSON - Full support for diagnostic notation -- Assign a field to a type base on the CBOR tag -- Support for a wide range of IANA CBOR Tags (see below) +- Support for a [wide range of IANA CBOR Tags](#supported-tags) +- Support custom CBOR Tags + +## Example + +```crystal +require "cbor" + +class Location + include CBOR::Serializable + + @[CBOR::Field(key: "lat")] + property latitude : Float64 + + @[CBOR::Field(key: "lng")] + property longitude : Float64 +end + +class House + include CBOR::Serializable + + property address : String + property location : Location? +end + +data = { + "address" => "Crystal Road 1234", + "location" => { "lat" => 12.3, "lng" => 34.5 } +} +cbor = data.to_cbor # => Bytes[...] +CBOR::Diagnostic.to_s(cbor) # => {"address": "Crystal Road 1234", "location": {"lat": 12.3, "lng": 34.5}} + +house = House.from_cbor(cbor) +house.address # => "Crystal Road 1234" +house.location # => # +bytes = house.to_cbor # => Bytes[...] +CBOR::Diagnostic.to_s(bytes) # => {_ "address": "Crystal Road 1234", "location": {_ "lat": 12.3, "lng": 34.5}} + +data_array = [data] +cbor_array = data_array.to_cbor # => Bytes[...] +CBOR::Diagnostic.to_s(cbor) # => [{"address": "Crystal Road 1234", "location": {"lat": 12.3, "lng": 34.5}}]} + +houses = Array(House).from_cbor(cbor_array) +houses.size # => 1 +bytes = houses.to_cbor # => Bytes[...] +CBOR::Diagnostic.to_s(bytes) # => [{_ "address": "Crystal Road 1234", "location": {_ "lat": 12.3, "lng": 34.5}}] +``` ## Installation @@ -27,11 +83,105 @@ in Crystal. ## Usage +Including `CBOR::Serializable` will create `#to_cbor` and `self.from_cbor` methods +on the current class, and a constructor which takes a `CBOR::Decoder`. + +By default, these methods serialize into a cbor map containing the value of +every instance variable, the keys being the instance variable name. + +Most primitives and collections are supported as instance variable values (string, +integer, array, hash, etc.), along with objects which define to_cbor and a +constructor taking a `CBOR::Decoder`. + +Union types are also supported, including unions with `nil`. If multiple types +in a union parse correctly, it is undefined which one will be chosen. + +To change how individual instance variables are parsed and serialized, +the annotation `CBOR::Field` can be placed on the instance variable. +Annotating property, getter and setter macros is also allowed. + ```crystal require "cbor" + +class A + include CBOR::Serializable + + @[CBOR::Field(key: "my_key")] + getter a : Int32? +end ``` -TODO: Write usage instructions here +### `CBOR::Field` properties + +- **ignore**: if `true` skip this field in serialization and deserialization + (by default `false`) +- **key**: the value of the key in the json object (by default the name of the + instance variable) +- **converter**: specify an alternate type for parsing and generation. + The converter must define `from_cbor(CBOR::Decoder)` and + `to_cbor(value, CBOR::Builder)` as class methods. Examples of converters are + `Time::Format::RFC_333` and `Time::EpochConverter` for `Time`. +- **presence**: if `true`, a `@{{key}}_present` instance variable will be generated + when the key was present (even if it has a `null` value), `false` by default +- **emit_null**: if `true`, emits a `null` value for nilable property + (by default nulls are not emitted) +- **nil_as_undefined**: if `true`, when the value is `nil`, it is emitted as + `undefined` (by default `nil` are encoded as `null`) + +Deserialization also respects default values of variables: + +``` +require "cbor" + +struct A + include CBOR::Serializable + @a : Int32 + @b : Float64 = 1.0 +end + +A.from_cbor({"a" => 1}.to_cbor) # => A(@a=1, @b=1.0) +``` + +### Extensions: `CBOR::Serializable::Unmapped` + +If the `CBOR::Serializable::Unmapped` module is included, unknown properties in +the CBOR document will be stored in a `Hash(String, CBOR::Type)`. + +On serialization, any keys inside `cbor_unmapped` will be serialized and appended +to the current cbor map. + +``` +require "cbor" + +struct A + include CBOR::Serializable + include CBOR::Serializable::Unmapped + @a : Int32 +end + +a = A.from_cbor({"a" => 1, "b" => 2}.to_cbor) # => A(@cbor_unmapped={"b" => 2}, @a=1) +bytes = a.to_cbor # => Bytes[...] +CBOR::Diagnostic.to_s(bytes) # => {_ "a": 1, "b": 2} +``` + +### Class annotation `CBOR::Serializable::Options` + +supported properties: + +- **emit_nulls**: if `true`, emits a `null` value for all nilable properties + (by default nulls are not emitted) +- **nil_as_undefined**: if `true`, emits a `nil` value as undefined + (by default nil emits `null`) + +``` +require "json" + +@[CBOR::Serializable::Options(emit_nulls: true)] +class A + include JSON::Serializable + @a : Int32? +end +``` ## Supported tags @@ -52,7 +202,7 @@ to be a `UInt64`. While this library supports lengths expressed as a `UInt64`, it must not exceed `Int32::MAX`. -## Development +## Community TODO: Write development instructions here diff --git a/spec/cbor/serializable_spec.cr b/spec/cbor/serializable_spec.cr index 1dbe4cf..ff64361 100644 --- a/spec/cbor/serializable_spec.cr +++ b/spec/cbor/serializable_spec.cr @@ -115,19 +115,48 @@ describe CBOR::Serializable do describe "documentation examples" do describe "house example" do - houses = [House.new(address: "Crystal Road 1234", location: Location.new(latitude: 12.3, longitude: 34.5))] - cbor_houses_bytes = Bytes[129, 191, 103, 97, 100, 100, 114, 101, 115, 115, 113, 67, 114, 121, 115, 116, 97, 108, 32, 82, 111, 97, 100, 32, 49, 50, 51, 52, 104, 108, 111, 99, 97, 116, 105, 111, 110, 191, 99, 108, 97, 116, 251, 64, 40, 153, 153, 153, 153, 153, 154, 99, 108, 110, 103, 251, 64, 65, 64, 0, 0, 0, 0, 0, 255, 255] + data = { + "address" => "Crystal Road 1234", + "location" => {"lat" => 12.3, "lng" => 34.5}, + } + bytes = data.to_cbor - it "encodes to cbor" do - cbor = houses.to_cbor - cbor.should eq(cbor_houses_bytes) + it "has the correct starting data" do + CBOR::Diagnostic.to_s(bytes).should eq(%({"address": "Crystal Road 1234", "location": {"lat": 12.3, "lng": 34.5}})) end - it "decodes form cbor" do - decoded = Array(House).from_cbor(cbor_houses_bytes) + it "decodes from CBOR" do + house = House.from_cbor(bytes) - decoded.size.should eq(1) - house = decoded[0] + house.address.should eq("Crystal Road 1234") + loc = house.location + loc.should_not be_nil + loc.not_nil!.latitude.should eq(12.3) + loc.not_nil!.longitude.should eq(34.5) + end + + it "encodes to CBOR" do + cbor = House.from_cbor(bytes).to_cbor + CBOR::Diagnostic.to_s(cbor).should eq(%({_ "address": "Crystal Road 1234", "location": {_ "lat": 12.3, "lng": 34.5}})) + end + end + + describe "houses array" do + data = [{ + "address" => "Crystal Road 1234", + "location" => {"lat" => 12.3, "lng" => 34.5}, + }] + bytes = data.to_cbor + + it "has the correct starting data" do + CBOR::Diagnostic.to_s(bytes).should eq(%([{"address": "Crystal Road 1234", "location": {"lat": 12.3, "lng": 34.5}}])) + end + + it "decodes from CBOR" do + houses = Array(House).from_cbor(bytes) + + houses.size.should eq(1) + house = houses[0] house.address.should eq("Crystal Road 1234") loc = house.location @@ -135,6 +164,12 @@ describe CBOR::Serializable do loc.not_nil!.latitude.should eq(12.3) loc.not_nil!.longitude.should eq(34.5) end + + it "encodes to CBOR" do + cbor = Array(House).from_cbor(bytes).to_cbor + + CBOR::Diagnostic.to_s(cbor).should eq(%([{_ "address": "Crystal Road 1234", "location": {_ "lat": 12.3, "lng": 34.5}}])) + end end describe "default values example" do @@ -142,5 +177,16 @@ describe CBOR::Serializable do A.from_cbor({"a" => 1}.to_cbor).inspect.should eq("A(@a=1, @b=1.0)") end end + + describe "Unmapped extension" do + it "decodes with the values in cbor_unmapped" do + res = ExampleUnmapped.from_cbor({"a" => 1, "b" => 2}.to_cbor) + + res.a.should eq(1) + res.cbor_unmapped.should eq({"b" => 2}) + + CBOR::Diagnostic.to_s(res.to_cbor).should eq(%({_ "a": 1, "b": 2})) + end + end end end diff --git a/src/cbor/diagnostic.cr b/src/cbor/diagnostic.cr index 00fac46..0233992 100644 --- a/src/cbor/diagnostic.cr +++ b/src/cbor/diagnostic.cr @@ -8,6 +8,10 @@ class CBOR::Diagnostic @lexer = Lexer.new(input) end + def self.to_s(bytes : Bytes) : String + self.new(bytes).to_s + end + # Reads the content of the IO and prints out a diagnostic string # represation of the input. def to_s : String diff --git a/src/cbor/encoder.cr b/src/cbor/encoder.cr index 945ed7e..e2dfba2 100644 --- a/src/cbor/encoder.cr +++ b/src/cbor/encoder.cr @@ -8,7 +8,7 @@ class CBOR::Encoder def initialize(@io : IO = IO::Memory.new) end - def write(value : Nil.class, use_undefined : Bool = false) + def write(value : Nil | Nil.class, use_undefined : Bool = false) write(use_undefined ? SimpleValue::Undefined : SimpleValue::Null) end diff --git a/src/cbor/serializable.cr b/src/cbor/serializable.cr index d43bd38..7d2fcd1 100644 --- a/src/cbor/serializable.cr +++ b/src/cbor/serializable.cr @@ -21,18 +21,19 @@ module CBOR # # class House # include CBOR::Serializable + # # property address : String # property location : Location? # end # - # house = House.from_cbor(Bytes[191, 103, 97, 100, 100, 114, 101, 115, 115, 113, 67, 114, 121, 115, 116, 97, 108, 32, 82, 111, 97, 100, 32, 49, 50, 51, 52, 104, 108, 111, 99, 97, 116, 105, 111, 110, 191, 99, 108, 97, 116, 251, 64, 40, 153, 153, 153, 153, 153, 154, 99, 108, 110, 103, 251, 64, 65, 64, 0, 0, 0, 0, 0, 255, 255]) + # house = House.from_cbor({"address" => "Crystal Road 1234", "location" => {"lat" => 12.3, "lng" => 34.5}}.to_cbor) # house.address # => "Crystal Road 1234" # house.location # => # - # house.to_cbor # => Bytes[191, 103, 97, 100, 100, 114, 101, 115, 115, 113, 67, 114, 121, 115, 116, 97, 108, 32, 82, 111, 97, 100, 32, 49, 50, 51, 52, 104, 108, 111, 99, 97, 116, 105, 111, 110, 191, 99, 108, 97, 116, 251, 64, 40, 153, 153, 153, 153, 153, 154, 99, 108, 110, 103, 251, 64, 65, 64, 0, 0, 0, 0, 0, 255, 255] + # house.to_cbor # => Bytes[...] # - # houses = Array(House).from_json(Bytes[129, 191, 103, 97, 100, 100, 114, 101, 115, 115, 113, 67, 114, 121, 115, 116, 97, 108, 32, 82, 111, 97, 100, 32, 49, 50, 51, 52, 104, 108, 111, 99, 97, 116, 105, 111, 110, 191, 99, 108, 97, 116, 251, 64, 40, 153, 153, 153, 153, 153, 154, 99, 108, 110, 103, 251, 64, 65, 64, 0, 0, 0, 0, 0, 255, 255]) + # houses = Array(House).from_cbor([{"address" => "Crystal Road 1234", "location" => {"lat" => 12.3, "lng" => 34.5}}].to_cbor) # houses.size # => 1 - # houses.to_json # => Bytes[129, 191, 103, 97, 100, 100, 114, 101, 115, 115, 113, 67, 114, 121, 115, 116, 97, 108, 32, 82, 111, 97, 100, 32, 49, 50, 51, 52, 104, 108, 111, 99, 97, 116, 105, 111, 110, 191, 99, 108, 97, 116, 251, 64, 40, 153, 153, 153, 153, 153, 154, 99, 108, 110, 103, 251, 64, 65, 64, 0, 0, 0, 0, 0, 255, 255] + # houses.to_cbor # Bytes[...] # ``` # # ### Usage diff --git a/src/cbor/to_cbor.cr b/src/cbor/to_cbor.cr index 40b78d0..b785d8c 100644 --- a/src/cbor/to_cbor.cr +++ b/src/cbor/to_cbor.cr @@ -74,25 +74,35 @@ module Time::Format::RFC_3339 # [RFC 7049 section 2.4.1](https://tools.ietf.org/html/rfc7049#section-2.4.1). def self.to_cbor(value : Time, encoder : CBOR::Encoder) encoder.write(CBOR::Tag::RFC3339Time) - self.format(self, fraction_digits: 0).to_cbor(encoder) + value.format(value, fraction_digits: 0).to_cbor(encoder) + end +end + +module Time::EpochConverter + # Emits the time as a tagged unix timestamp, asp specified by + # [RFC 7049 section 2.4.1](https://tools.ietf.org/html/rfc7049#section-2.4.1). + # + def self.to_cbor(value : Time, encoder : CBOR::Encoder) + encoder.write(CBOR::Tag::EpochTime) + value.to_unix.to_cbor(encoder) end end struct Time - # Emits the time as a tagged unix timestamp, asp specified by + # Encodes the time as a properly tagged CBOR string as specified by # [RFC 7049 section 2.4.1](https://tools.ietf.org/html/rfc7049#section-2.4.1). # - # If you would like to encode the time as a tagged RFC 3339 string isntead, - # you can tag the field with the `Time::Format::RFC_3339` instead: + # If you would like to encode it as a unix timestamp, you can instead specify + # `Time::EpochConverter`: # # ``` # class Foo - # @[CBOR::Filed(converter: Time::Format::RFC_3339)] + # @[CBOR::Filed(converter: Time::EpochConverter)] # property created_at : Time # end # ``` def to_cbor(encoder : CBOR::Encoder) - encoder.write(CBOR::Tag::EpochTime) - self.to_unix.to_cbor(encoder) + encoder.write(CBOR::Tag::RFC3339Time) + self.format(self, fraction_digits: 0).to_cbor(encoder) end end