Compare commits

...

16 Commits
dev ... master

9 changed files with 801 additions and 28 deletions

View File

@ -1,5 +1,232 @@
require "./spec_helper"
describe CBOR do
# TODO: Write tests
class Location
include CBOR::Serializable
@[CBOR::Field(key: "lat")]
property latitude : Float64
@[CBOR::Field(key: "lng")]
property longitude : Float64
def initialize(@latitude, @longitude)
end
end
class House
include CBOR::Serializable
property address : String
property location : Location?
def initialize(@address)
end
end
class Person
include CBOR::Serializable
include CBOR::Serializable::Unmapped
property name : String?
def initialize(@name = nil)
end
end
class Game
include CBOR::Serializable
abstract class Player
include CBOR::Serializable
@[CBOR::Field(key: "hp")]
property health_points : Int32 = 100
@[CBOR::Field(key: "dp")]
property defense_points : Int32 = 5
use_cbor_discriminator "type", {
magician: Magician,
warrior: Warrior
}
def initialize
end
end
class Magician < Player
property wand_level : Int32 = 1
def initialize
@type = "magician"
end
end
class Warrior < Player
def initialize
@defense_points = 20
@type = "warrior"
end
end
property players : Array(Magician | Warrior)
def initialize
@players = [] of (Magician | Warrior)
end
end
describe CBOR do
describe "basics: to_cbor" do
it "empty array" do
empty_array_cbor = [] of Nil
empty_array_cbor.to_cbor.hexstring.should eq "80"
end
it "array - strings" do
["a", "b", "c"].to_cbor.hexstring.should eq "83616161626163"
end
it "empty hash" do
empty = {} of Nil => Nil
cbor_stuff = empty.to_cbor
cbor_stuff.hexstring.should eq "a0"
end
it "hash" do
{"a" => 10, "b" => true, "c" => nil}.to_cbor.hexstring.should eq "a361610a6162f56163f6"
end
it "union String | Int32" do
value = (String | Int32).from_cbor(30.to_cbor).to_cbor
value.hexstring.should eq "181e"
end
it "union (String | Int32)" do
value = (String | Int32).from_cbor("blah".to_cbor).to_cbor
value.hexstring.should eq "64626c6168"
end
it "union (Bool | Int32)" do
value = (Bool | Int32).from_cbor(30.to_cbor).to_cbor
value.hexstring.should eq "181e"
end
it "union (Bool | Int32)" do
value = (Bool | Int32).from_cbor(false.to_cbor).to_cbor
value.hexstring.should eq "f4"
end
it "union (String | Bool | Int32)" do
value = (String | Bool | Int32).from_cbor("hello".to_cbor).to_cbor
value.hexstring.should eq "6568656c6c6f"
end
it "union (String | Nil | Int32)" do
value = (String | Nil | Int32).from_cbor(nil.to_cbor).to_cbor
value.hexstring.should eq "f6"
end
end
describe "Time" do
it "Time#to_cbor" do
time = Time.utc(2016, 2, 15, 10, 20, 30)
time.to_cbor.hexstring.should eq "c074323031362d30322d31355431303a32303a33305a"
end
it "Time#from_cbor" do
time = Time.from_cbor Time.utc(2016, 2, 15, 10, 20, 30).to_cbor
time.to_cbor.hexstring.should eq "c074323031362d30322d31355431303a32303a33305a"
end
end
describe "UUID" do
it "UUID#to_cbor" do
uuid = UUID.new "fc47eb8e-b13c-481e-863a-8f8c47a550f2"
uuid.to_cbor.hexstring.should eq "50fc47eb8eb13c481e863a8f8c47a550f2"
end
it "UUID#from_cbor" do
uuid = UUID.from_cbor "50fc47eb8eb13c481e863a8f8c47a550f2".hexbytes
uuid.to_cbor.hexstring.should eq "50fc47eb8eb13c481e863a8f8c47a550f2"
end
end
describe "CBOR library annotations and features" do
it "House#to_cbor with CBOR::Field annotations" do
house = House.new "my address"
house.location = Location.new 1.1, 1.2
house.to_cbor.hexstring.should eq "a267616464726573736a6d792061646472657373686c6f636174696f6ea2636c6174fb3ff199999999999a636c6e67fb3ff3333333333333"
end
it "House#from_cbor with CBOR::Field annotations" do
other_house = House.new "my address"
other_house.location = Location.new 1.1, 1.2
house = House.from_cbor other_house.to_cbor
house.to_cbor.hexstring.should eq "a267616464726573736a6d792061646472657373686c6f636174696f6ea2636c6174fb3ff199999999999a636c6e67fb3ff3333333333333"
end
it "Person#from_cbor with unmapped values" do
h = Hash(String | Int32, String | Int32).new
h["name"] = "Alice"
h["age"] = 30
h["size"] = 160
alice = Person.from_cbor h.to_cbor
alice.to_cbor.hexstring.should eq "a3646e616d6565416c69636563616765181e6473697a6518a0"
end
it "Person#to_cbor with unmapped values" do
alice = Person.new "Alice"
alice.cbor_unmapped["age"] = 30
alice.cbor_unmapped["size"] = 160
alice.to_cbor.hexstring.should eq "a3646e616d6565416c69636563616765181e6473697a6518a0"
end
end
describe "CBOR::Any" do
it "from JSON::Any - 1" do
h_any = {"a" => "b", "c" => "d", "e" => true, "f" => 10}
json_any = JSON.parse h_any.to_json
cbor_any = CBOR::Any.new json_any
cbor_any.to_cbor.hexstring.should eq "a461616162616361646165f561660a"
end
it "from JSON::Any - 2" do
h_any = {"a" => "b", "c" => "d", "e" => true, "f" => 10}
json_any = JSON.parse h_any.to_json
cbor_any = CBOR::Any.new json_any
cbor_any["f"].should eq 10
end
it "from array" do
array = CBOR::Any.from_cbor [ "a", "b", "c", "d" ].to_cbor
array.to_cbor.hexstring.should eq "846161616261636164"
end
it "from hash" do
h_cany = Hash(String | Int32, String | Int32).new
h_cany["name"] = "Alice"
h_cany["age"] = 30
h_cany["size"] = 160
cbor_any_hash = CBOR::Any.from_cbor h_cany.to_cbor
cbor_any_hash.to_cbor.hexstring.should eq "a3646e616d6565416c69636563616765181e6473697a6518a0"
end
end
# db[i]?.should be_nil
describe "Complex object representations" do
it "Game#to_cbor with use_cbor_discriminator" do
game = Game.new
magician = Game::Magician.new
magician.wand_level = 5
game.players << magician
game.players << Game::Warrior.new
game.to_cbor.hexstring.should eq "a167706c617965727382a46268701864626470056a77616e645f6c6576656c056474797065686d6167696369616ea362687018646264701464747970656777617272696f72"
end
it "Game#from_cbor with use_cbor_discriminator" do
game = Game.new
magician = Game::Magician.new
magician.wand_level = 5
game.players << magician
game.players << Game::Warrior.new
new_game = Game.from_cbor game.to_cbor
new_game.to_cbor.hexstring.should eq "a167706c617965727382a46268701864626470056a77616e645f6c6576656c056474797065686d6167696369616ea362687018646264701464747970656777617272696f72"
end
end
end

View File

@ -5,13 +5,32 @@ require "./cbor/**"
module CBOR
VERSION = "0.1.0"
# Represents CBOR Hash Keys: everything except Nil, hashs, arrays.
alias HashKeyType = Int8 | Int16 | Int32 | Int64 | Int128 |
UInt8 | UInt16 | UInt32 | UInt64 |
Float32 | Float64 |
String |
Bool |
Bytes
# Represents CBOR types
alias Type = Nil |
Bool |
String |
Bytes |
Array(Type) |
Hash(Type, Type) |
# Hash(Int8, Type) |
# Hash(Int16, Type) |
# Hash(Int32, Type) |
# Hash(Int64, Type) |
# Hash(UInt8, Type) |
# Hash(UInt16, Type) |
# Hash(UInt32, Type) |
# Hash(UInt64, Type) |
# Hash(Float32, Type) |
# Hash(Float64, Type) |
# Hash(String, Type) |
Hash(HashKeyType, Type) |
Int8 |
UInt8 |
Int16 |

395
src/cbor/any.cr Normal file
View File

@ -0,0 +1,395 @@
# `CBOR::Any` is a convenient wrapper around all possible CBOR types (`CBOR::Any::Type`)
# and can be used for traversing dynamic or unknown CBOR structures.
#
# ```
# require "json"
#
# obj = CBOR::Any.new JSON.parse(%({"access": [{"name": "mapping", "speed": "fast"}, {"name": "any", "speed": "slow"}]}))
# obj["access"][1]["name"].as_s # => "any"
# obj["access"][1]["speed"].as_s # => "slow"
#
# # through a cbor buffer
# hash = {"access" => [{"name" => "mapping", "speed" => "fast"}, {"name" => "any", "speed" => "slow"}]}
# obj2 = CBOR::Any.new hash.to_cbor
# obj2["access"][1]["name"].as_s # => "any"
# obj2["access"][1]["speed"].as_s # => "slow"
# ```
#
# Note that methods used to traverse a CBOR structure, `#[]` and `#[]?`,
# always return a `CBOR::Any` to allow further traversal. To convert them to `String`,
# `Int32`, etc., use the `as_` methods, such as `#as_s`, `#as_i`, which perform
# a type check against the raw underlying value. This means that invoking `#as_s`
# when the underlying value is not a String will raise: the value won't automatically
# be converted (parsed) to a `String`.
struct CBOR::Any
# All possible CBOR types.
alias Type = Nil | Bool | String | Bytes |
Int8 | UInt8 | Int16 | UInt16 | Int32 | UInt32 | Int64 | UInt64 | Int128 |
Float32 | Float64 |
Array(Any) |
Hash(String, Any) | Hash(Any, Any) |
Time
# Reads a `CBOR::Any` from a JSON::Any structure.
def self.new(json : JSON::Any)
new json.raw
end
def self.new(value)
case value
when Nil
new nil
when Bool
new value
when Int64
new value
when Float64
new value
when String
new value
when Time
new value
when Array(Type)
ary = [] of CBOR::Any
value.each do |v|
ary << new(v)
end
new ary
when Array(JSON::Any)
ary = [] of CBOR::Any
value.each do |v|
ary << new(v)
end
new ary
when Array(CBOR::Type)
ary = [] of CBOR::Any
value.each do |v|
ary << new(v)
end
new ary
when Hash(String, JSON::Any)
hash = {} of String => CBOR::Any
value.each do |key, v|
hash[key] = new(v)
end
new hash
when Hash(CBOR::Type, CBOR::Type)
hash = {} of CBOR::Any => CBOR::Any
value.each do |key, v|
hash[new(key)] = new(v)
end
new hash
when Hash(String, Type)
hash = {} of String => CBOR::Any
value.each do |key, v|
hash[key] = new(v)
end
new hash
else
raise "Unknown value type: #{value.class}"
end
end
# Reads a `CBOR::Any` from a Decoder.
def self.new(decoder : CBOR::Decoder)
new decoder.read_value
end
# Reads a `CBOR::Any` from a buffer.
def self.new(input : Slice(UInt8))
new CBOR::Decoder.new(input)
end
# Returns the raw underlying value.
getter raw : Type
# Creates a `CBOR::Any` that wraps the given value.
def initialize(@raw : Type)
end
# Assumes the underlying value is an `Array` or `Hash` and returns its size.
# Raises if the underlying value is not an `Array` or `Hash`.
def size : Int
case object = @raw
when Array
object.size
when Hash
object.size
else
raise "Expected Array or Hash for #size, not #{object.class}"
end
end
# Assumes the underlying value is an `Array` and returns the element
# at the given index.
# Raises if the underlying value is not an `Array`.
def [](index : Int) : CBOR::Any
case object = @raw
when Array
object[index]
else
raise "Expected Array for #[](index : Int), not #{object.class}"
end
end
# Assumes the underlying value is an `Array` and returns the element
# at the given index, or `nil` if out of bounds.
# Raises if the underlying value is not an `Array`.
def []?(index : Int) : CBOR::Any?
case object = @raw
when Array
object[index]?
else
raise "Expected Array for #[]?(index : Int), not #{object.class}"
end
end
# Assumes the underlying value is a `Hash` and returns the element
# with the given key.
# Raises if the underlying value is not a `Hash`.
def [](key : String) : CBOR::Any
case object = @raw
when Hash
object[key]
else
raise "Expected Hash for #[](key : String), not #{object.class}"
end
end
# Assumes the underlying value is a `Hash` and returns the element
# with the given key, or `nil` if the key is not present.
# Raises if the underlying value is not a `Hash`.
def []?(key : String) : CBOR::Any?
case object = @raw
when Hash
object[key]?
else
raise "Expected Hash for #[]?(key : String), not #{object.class}"
end
end
# Traverses the depth of a structure and returns the value.
# Returns `nil` if not found.
def dig?(key : String | Int, *subkeys)
if value = self[key]?
value.dig?(*subkeys)
end
end
# :nodoc:
def dig?(key : String | Int)
case @raw
when Hash, Array
self[key]?
else
nil
end
end
# Traverses the depth of a structure and returns the value, otherwise raises.
def dig(key : String | Int, *subkeys)
if (value = self[key]) && value.responds_to?(:dig)
return value.dig(*subkeys)
end
raise "CBOR::Any value not diggable for key: #{key.inspect}"
end
# :nodoc:
def dig(key : String | Int)
self[key]
end
# Checks that the underlying value is `Nil`, and returns `nil`.
# Raises otherwise.
def as_nil : Nil
@raw.as(Nil)
end
# Checks that the underlying value is `Bool`, and returns its value.
# Raises otherwise.
def as_bool : Bool
@raw.as(Bool)
end
# Checks that the underlying value is `Bool`, and returns its value.
# Returns `nil` otherwise.
def as_bool? : Bool?
as_bool if @raw.is_a?(Bool)
end
# Checks that the underlying value is `Int`, and returns its value as an `Int32`.
# Raises otherwise.
def as_i : Int32
@raw.as(Int).to_i
end
# Checks that the underlying value is `Int`, and returns its value as an `Int32`.
# Returns `nil` otherwise.
def as_i? : Int32?
as_i if @raw.is_a?(Int)
end
# Checks that the underlying value is `Int`, and returns its value as an `Int64`.
# Raises otherwise.
def as_i64 : Int64
@raw.as(Int).to_i64
end
# Checks that the underlying value is `Int`, and returns its value as an `Int64`.
# Returns `nil` otherwise.
def as_i64? : Int64?
as_i64 if @raw.is_a?(Int64)
end
# Checks that the underlying value is `Float`, and returns its value as an `Float64`.
# Raises otherwise.
def as_f : Float64
@raw.as(Float64)
end
# Checks that the underlying value is `Float`, and returns its value as an `Float64`.
# Returns `nil` otherwise.
def as_f? : Float64?
@raw.as?(Float64)
end
# Checks that the underlying value is `Float`, and returns its value as an `Float32`.
# Raises otherwise.
def as_f32 : Float32
@raw.as(Float).to_f32
end
# Checks that the underlying value is `Float`, and returns its value as an `Float32`.
# Returns `nil` otherwise.
def as_f32? : Float32?
as_f32 if @raw.is_a?(Float)
end
# Checks that the underlying value is `String`, and returns its value.
# Raises otherwise.
def as_s : String
@raw.as(String)
end
# Checks that the underlying value is `String`, and returns its value.
# Returns `nil` otherwise.
def as_s? : String?
as_s if @raw.is_a?(String)
end
# Checks that the underlying value is `Array`, and returns its value.
# Raises otherwise.
def as_a : Array(Any)
@raw.as(Array)
end
# Checks that the underlying value is `Array`, and returns its value.
# Returns `nil` otherwise.
def as_a? : Array(Any)?
as_a if @raw.is_a?(Array)
end
# Checks that the underlying value is `Hash`, and returns its value.
# Raises otherwise.
def as_h : Hash(String, Any)
@raw.as(Hash)
end
# Checks that the underlying value is `Hash`, and returns its value.
# Returns `nil` otherwise.
def as_h? : Hash(String, Any)?
as_h if @raw.is_a?(Hash)
end
# :nodoc:
def inspect(io : IO) : Nil
@raw.inspect(io)
end
# :nodoc:
def to_s(io : IO) : Nil
@raw.to_s(io)
end
# :nodoc:
def pretty_print(pp)
@raw.pretty_print(pp)
end
# Returns `true` if both `self` and *other*'s raw object are equal.
def ==(other : CBOR::Any)
raw == other.raw
end
# Returns `true` if the raw object is equal to *other*.
def ==(other)
raw == other
end
# See `Object#hash(hasher)`
def_hash raw
# :nodoc:
def to_json(json : JSON::Builder)
raw.to_json(json)
end
def to_yaml(yaml : YAML::Nodes::Builder)
raw.to_yaml(yaml)
end
# Returns a new CBOR::Any instance with the `raw` value `dup`ed.
def dup
Any.new(raw.dup)
end
# Returns a new CBOR::Any instance with the `raw` value `clone`ed.
def clone
Any.new(raw.clone)
end
end
class Object
def ===(other : CBOR::Any)
self === other.raw
end
end
struct Value
def ==(other : CBOR::Any)
self == other.raw
end
end
class Reference
def ==(other : CBOR::Any)
self == other.raw
end
end
class Array
def ==(other : CBOR::Any)
self == other.raw
end
end
class Hash
def ==(other : CBOR::Any)
self == other.raw
end
end
class Regex
def ===(other : CBOR::Any)
value = self === other.raw
$~ = $~
value
end
end

View File

@ -2,9 +2,17 @@ class CBOR::Decoder
@lexer : Lexer
getter current_token : Token::T?
def reset
# Decode until a certain point in the history (use_cbor_discriminator helper).
def reset(value : Int32 | Int64 = 0)
@lexer.reset
@current_token = @lexer.next_token
while pos < value
@current_token = @lexer.next_token
end
end
# Give the current position in the decoder (use_cbor_discriminator helper).
def pos
@lexer.io.pos
end
def initialize(input)
@ -12,6 +20,34 @@ class CBOR::Decoder
@current_token = @lexer.next_token
end
# This is similar to read_value but with a focus on a key for a hash.
def read_key : HashKeyType
v = case token = @current_token
when Token::StringT
finish_token!
token.value.as(String)
when Token::IntT
finish_token!
token.value
when Token::FloatT
finish_token!
token.value
when Token::BytesT
finish_token!
token.value.as(Bytes)
when Token::SimpleValueT
finish_token!
token.value.to_t
else
puts "hash key with a #{token.class.to_s} value"
unexpected_token(token)
end
if v.nil?
raise ""
end
v
end
def read_value : Type
case token = @current_token
when Token::TagT
@ -30,6 +66,7 @@ class CBOR::Decoder
finish_token!
token.value
when Token::SimpleValueT
finish_token!
token.value.to_t
when Token::ArrayT
finish_token!
@ -38,14 +75,25 @@ class CBOR::Decoder
arr
when Token::MapT
finish_token!
map = Hash(Type, Type).new
consume_sequence(token.size) { map[read_value] = read_value }
map = Hash(HashKeyType, Type).new
consume_sequence(token.size) {
key = read_key
map[key] = read_value
}
map
else
unexpected_token(token)
end
end
def read_simple_value : Bool?
case token = @current_token
when Token::SimpleValueT
finish_token!
token.value.to_t
end
end
def read_string : String
case token = @current_token
when Token::StringT

View File

@ -1,3 +1,5 @@
require "json"
class CBOR::Encoder
def self.new(io : IO = IO::Memory.new)
packer = new(io)
@ -8,6 +10,14 @@ class CBOR::Encoder
def initialize(@io : IO = IO::Memory.new)
end
def write(cbor : CBOR::Any)
write cbor.raw
end
def write(j : JSON::Any)
write j.raw
end
def write(value : Nil | Nil.class, use_undefined : Bool = false)
write(use_undefined ? SimpleValue::Undefined : SimpleValue::Null)
end
@ -34,6 +44,12 @@ class CBOR::Encoder
write(value.to_s)
end
def write(value : Time)
write(CBOR::Tag::RFC3339Time)
write(value.to_rfc3339)
end
def write(value : Float32 | Float64)
case value
when Float32

View File

@ -137,20 +137,32 @@ end
# specified by [Section 2.4.1 of RFC 7049][1].
#
# [1]: https://tools.ietf.org/html/rfc7049#section-2.4.1
def Time.new(decoder : CBOR::Decoder)
case tag = decoder.read_tag
when CBOR::Tag::RFC3339Time
def Time.new(decoder : CBOR::Decoder) : Time
# In case Time is formatted as a JSON#to_json String.
value = case decoder.current_token
when CBOR::Token::StringT
Time::Format::RFC_3339.parse(decoder.read_string)
when CBOR::Tag::EpochTime
case num = decoder.read_num
when Int
Time.unix(num)
when Float
Time.unix_ms((BigFloat.new(num) * 1_000).to_u64)
end
else
raise CBOR::ParseError.new("Expected tag to have value 0 or 1, got #{tag.value}")
case tag = decoder.read_tag
when CBOR::Tag::RFC3339Time
Time::Format::RFC_3339.parse(decoder.read_string)
when CBOR::Tag::EpochTime
case num = decoder.read_num
when Int
Time.unix(num)
when Float
Time.unix_ms((BigFloat.new(num) * 1_000).to_u64)
end
else
raise CBOR::ParseError.new("Expected tag to have value 0 or 1, got #{tag.value}")
end
end
unless value
raise CBOR::ParseError.new("could not parse time representation")
end
value
end
# Reads the CBOR value as a BigInt.
@ -212,14 +224,21 @@ end
def Union.new(decoder : CBOR::Decoder)
{% begin %}
case decoder.current_token
{% if T.includes? Nil %}
when CBOR::Token::SimpleValueT
return decoder.read_nil
{% end %}
{% if T.includes? Bool %}
when CBOR::Token::SimpleValueT
return decoder.read_bool
{% end %}
# This value could be either a boolean or nil.
value = decoder.read_simple_value
case value
{% if T.includes? Bool %}
when Bool
return value
{% end %}
{% if T.includes? Nil %}
when Nil
return nil
{% end %}
else
raise "value is neither Bool or Nil"
end
{% if T.includes? String %}
when CBOR::Token::StringT
return decoder.read_string

View File

@ -1,4 +1,5 @@
class CBOR::Lexer
property io : IO
def self.new(slice : Bytes)
new IO::Memory.new(slice)
end
@ -8,8 +9,8 @@ class CBOR::Lexer
def initialize(@io : IO)
end
def reset
@io.seek 0
def reset(value : Int32 | Int64 = 0)
@io.seek value
@eof = false
end

View File

@ -353,8 +353,9 @@ module CBOR
# SLOW. Read everything, get the type, read everything again.
def self.new(decoder : ::CBOR::Decoder)
current_offset = decoder.pos
if v = decoder.read_value
decoder.reset
decoder.reset current_offset
case v
when Hash(CBOR::Type, CBOR::Type)
discriminator_value = v[{{field.id.stringify}}]?

47
src/cbor/uuid.cr Normal file
View File

@ -0,0 +1,47 @@
require "../cbor"
require "uuid"
struct UUID
# Creates UUID from CBOR using `CBOR::Decoder`.
#
# NOTE: `require "uuid/cbor"` is required to opt-in to this feature.
#
# ```
# require "cbor"
# require "uuid"
# require "uuid/cbor"
#
# class Example
# include CBOR::Serializable
#
# property id : UUID
# end
#
# hash = {"id" => "ba714f86-cac6-42c7-8956-bcf5105e1b81"}
# example = Example.from_cbor hash.to_cbor
# example.id # => UUID(ba714f86-cac6-42c7-8956-bcf5105e1b81)
# ```
def self.new(pull : CBOR::Decoder)
# Either the UUID was encoded as String or bytes (smaller).
case pull.current_token
when CBOR::Token::StringT
new(pull.read_string)
when CBOR::Token::BytesT
new(pull.read_bytes)
else
raise "trying to get an UUID, but CBOR value isn't a string nor bytes: #{pull.current_token}"
end
end
# Returns UUID as CBOR value.
#
# NOTE: `require "uuid/cbor"` is required to opt-in to this feature.
#
# ```
# uuid = UUID.new("87b3042b-9b9a-41b7-8b15-a93d3f17025e")
# uuid.to_cbor
# ```
def to_cbor(cbor : CBOR::Encoder)
cbor.write(@bytes.to_slice)
end
end