Improve documentation and examples tests

dev
Alberto Restifo 2020-05-30 12:37:12 +02:00
parent 6201f2564f
commit b666ac0c84
6 changed files with 237 additions and 26 deletions

160
README.md
View File

@ -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 # => #<Location:0x10cd93d80 @latitude=12.3, @longitude=34.5>
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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 # => #<Location:0x10cd93d80 @latitude=12.3, @longitude=34.5>
# 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

View File

@ -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