Skip to content

Commit

Permalink
Rewrite using postfix tag syntax
Browse files Browse the repository at this point in the history
This is a nearly complete rewrite of the library using the new postfix tag
syntax described here:

tjson/tjson-spec#30
  • Loading branch information
tarcieri committed Nov 5, 2016
1 parent f9cacbf commit 64e3e6e
Show file tree
Hide file tree
Showing 24 changed files with 465 additions and 388 deletions.
3 changes: 2 additions & 1 deletion Guardfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true
# More info at https://github.com/guard/guard#readme

guard :rspec, :cmd => "GUARD_RSPEC=1 bundle exec rspec --no-profile" do
guard :rspec, cmd: "GUARD_RSPEC=1 bundle exec rspec --no-profile" do
require "guard/rspec/dsl"
dsl = Guard::RSpec::Dsl.new(self)

Expand Down
14 changes: 9 additions & 5 deletions lib/tjson.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@
require "base32"
require "base64"

require "tjson/array"
require "tjson/binary"
require "tjson/datatype"
require "tjson/generator"
require "tjson/object"
require "tjson/parser"

# Tagged JSON with Rich Types
module TJSON
Expand All @@ -24,6 +23,9 @@ module TJSON
# Failure to parse TJSON document
ParseError = Class.new(Error)

# Invalid types
TypeError = Class.new(ParseError)

# Duplicate object name
DuplicateNameError = Class.new(ParseError)

Expand All @@ -43,18 +45,20 @@ def self.parse(string)
end

begin
::JSON.parse(
object = ::JSON.parse(
utf8_string,
max_nesting: MAX_NESTING,
allow_nan: false,
symbolize_names: false,
create_additions: false,
object_class: TJSON::Object,
array_class: TJSON::Array
object_class: TJSON::Object
)
rescue ::JSON::ParserError => ex
raise TJSON::ParseError, ex.message, ex.backtrace
end

raise TJSON::TypeError, "invalid toplevel type: #{object.class}" unless object.is_a?(TJSON::Object)
object
end

# Generate TJSON from a Ruby Hash or Array
Expand Down
10 changes: 0 additions & 10 deletions lib/tjson/array.rb

This file was deleted.

56 changes: 56 additions & 0 deletions lib/tjson/datatype.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true

module TJSON
# Hierarchy of TJSON types
class DataType
# Find a type by its tag
def self.[](tag)
TAGS[tag] || raise(TJSON::TypeError, "unknown tag: #{tag.inspect}")
end

def self.parse(tag)
raise TJSON::TypeError, "expected String, got #{tag.class}" unless tag.is_a?(::String)

if tag == "O"
# Object
TJSON::DataType[tag]
elsif (result = tag.match(/\A(?<type>[A-Z][a-z0-9]*)\<(?<inner>.*)\>\z/))
# Non-scalar
TJSON::DataType[result[:type]].new(parse(result[:inner])).freeze
elsif tag =~ /\A[a-z][a-z0-9]*\z/
# Scalar
TJSON::DataType[tag]
else
raise TJSON::ParseError, "couldn't parse tag: #{tag.inspect}" unless result
end
end
end
end

require "tjson/datatype/nonscalar"
require "tjson/datatype/scalar"

require "tjson/datatype/binary"
require "tjson/datatype/number"
require "tjson/datatype/string"
require "tjson/datatype/timestamp"

# TJSON does not presently support user-extensible types
TJSON::DataType::TAGS = {
# Object (non-scalar with self-describing types)
"O" => TJSON::DataType::Object.new(nil).freeze,

# Non-scalars
"A" => TJSON::DataType::Array,

# Scalars
"b" => TJSON::DataType::Binary64.new.freeze,
"b16" => TJSON::DataType::Binary16.new.freeze,
"b32" => TJSON::DataType::Binary32.new.freeze,
"b64" => TJSON::DataType::Binary64.new.freeze,
"f" => TJSON::DataType::Float.new.freeze,
"i" => TJSON::DataType::SignedInt.new.freeze,
"s" => TJSON::DataType::UnicodeString.new.freeze,
"t" => TJSON::DataType::Timestamp.new.freeze,
"u" => TJSON::DataType::UnsignedInt.new.freeze
}.freeze
46 changes: 46 additions & 0 deletions lib/tjson/datatype/binary.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_string_literal: true

module TJSON
class DataType
# Binary Data
class Binary < Scalar; end

# Base16-serialized binary data
class Binary16 < Binary
def convert(str)
raise TJSON::TypeError, "expected String, got #{str.class}: #{str.inspect}" unless str.is_a?(::String)
raise TJSON::ParseError, "base16 must be lower case: #{str.inspect}" if str =~ /[A-F]/
raise TJSON::ParseError, "invalid base16: #{str.inspect}" unless str =~ /\A[a-f0-9]*\z/

[str].pack("H*")
end
end

# Base32-serialized binary data
class Binary32 < Binary
def convert(str)
raise TJSON::TypeError, "expected String, got #{str.class}: #{str.inspect}" unless str.is_a?(::String)
raise TJSON::ParseError, "base32 must be lower case: #{str.inspect}" if str =~ /[A-Z]/
raise TJSON::ParseError, "padding disallowed: #{str.inspect}" if str.include?("=")
raise TJSON::ParseError, "invalid base32: #{str.inspect}" unless str =~ /\A[a-z2-7]*\z/

::Base32.decode(str.upcase).force_encoding(Encoding::BINARY)
end
end

# Base64-serialized binary data
class Binary64 < Binary
def convert(str)
raise TJSON::TypeError, "expected String, got #{str.class}: #{str.inspect}" unless str.is_a?(::String)
raise TJSON::ParseError, "base64url only: #{str.inspect}" if str =~ %r{\+|\/}
raise TJSON::ParseError, "padding disallowed: #{str.inspect}" if str.include?("=")
raise TJSON::ParseError, "invalid base64url: #{str.inspect}" unless str =~ /\A[A-Za-z0-9\-_]*\z/

# Add padding, as older Rubies (< 2.3) require it
str = str.ljust((str.length + 3) & ~3, "=") if (str.length % 4).nonzero?

::Base64.urlsafe_decode64(str)
end
end
end
end
44 changes: 44 additions & 0 deletions lib/tjson/datatype/nonscalar.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

module TJSON
class DataType
# Non-scalar types
class NonScalar < TJSON::DataType
attr_reader :inner_type

def initialize(inner_type)
@inner_type = inner_type
end

def inspect
"#<#{self.class}<#{@inner_type.inspect}>>"
end

def scalar?
false
end
end

# TJSON arrays
class Array < NonScalar
def convert(array)
raise TJSON::TypeError, "expected Array, got #{array.class}" unless array.is_a?(::Array)
array.map! { |o| @inner_type.convert(o) }
end
end

# TJSON objects
class Object < NonScalar
def convert(obj)
raise TJSON::TypeError, "expected TJSON::Object, got #{obj.class}" unless obj.is_a?(TJSON::Object)

# Objects handle their own member conversions
obj
end

def inspect
"#<#{self.class}>"
end
end
end
end
44 changes: 44 additions & 0 deletions lib/tjson/datatype/number.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

module TJSON
class DataType
# Numbers
class Number < Scalar; end
class Integer < Scalar; end

# Floating point type
class Float < Number
def convert(float)
raise TJSON::TypeError, "expected Float, got #{float.class}" unless float.is_a?(::Numeric)
float.to_f
end
end

# Signed 64-bit integer
class SignedInt < Integer
def convert(str)
raise TJSON::TypeError, "expected String, got #{str.class}: #{str.inspect}" unless str.is_a?(::String)
raise TJSON::ParseError, "invalid integer: #{str.inspect}" unless str =~ /\A\-?(0|[1-9][0-9]*)\z/

result = Integer(str, 10)
raise TJSON::ParseError, "oversized integer: #{result}" if result > 9_223_372_036_854_775_807
raise TJSON::ParseError, "undersized integer: #{result}" if result < -9_223_372_036_854_775_808

result
end
end

# Unsigned 64-bit integer
class UnsignedInt < Integer
def convert(str)
raise TJSON::TypeError, "expected String, got #{str.class}: #{str.inspect}" unless str.is_a?(::String)
raise TJSON::ParseError, "invalid integer: #{str.inspect}" unless str =~ /\A(0|[1-9][0-9]*)\z/

result = Integer(str, 10)
raise TJSON::ParseError, "oversized integer: #{result}" if result > 18_446_744_073_709_551_615

result
end
end
end
end
16 changes: 16 additions & 0 deletions lib/tjson/datatype/scalar.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

module TJSON
class DataType
# Scalar types
class Scalar < TJSON::DataType
def scalar?
true
end

def inspect
"#<#{self.class}>"
end
end
end
end
14 changes: 14 additions & 0 deletions lib/tjson/datatype/string.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

module TJSON
class DataType
# Unicode String type
class UnicodeString < Scalar
def convert(str)
raise TJSON::TypeError, "expected String, got #{str.class}: #{str.inspect}" unless str.is_a?(::String)
raise TJSON::EncodingError, "expected UTF-8, got #{str.encoding.inspect}" unless str.encoding == Encoding::UTF_8
str
end
end
end
end
15 changes: 15 additions & 0 deletions lib/tjson/datatype/timestamp.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module TJSON
class DataType
# RFC3339 timestamp (Z-normalized)
class Timestamp < Scalar
def convert(str)
raise TJSON::TypeError, "expected String, got #{str.class}: #{str.inspect}" unless str.is_a?(::String)
raise TJSON::ParseError, "invalid timestamp: #{str.inspect}" unless str =~ /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/

::Time.iso8601(str)
end
end
end
end
15 changes: 7 additions & 8 deletions lib/tjson/object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@
module TJSON
# TJSON object type (i.e. hash/dict-alike)
class Object < ::Hash
def []=(name, value)
unless name.start_with?("s:", "b16:", "b64:")
raise TJSON::ParseError, "invalid tag on object name: #{name[/\A(.*?):/, 1]}" if name.include?(":")
raise TJSON::ParseError, "no tag found on object name: #{name.inspect}"
end
def []=(tagged_name, value)
# NOTE: this regex is sloppy. The real parsing is performed in TJSON::DataType#parse
result = tagged_name.match(/\A(?<name>.*):(?<tag>[A-Za-z0-9\<]+[\>]*)\z/)

name = TJSON::Parser.value(name)
raise TJSON::DuplicateNameError, "duplicate member name: #{name.inspect}" if key?(name)
raise ParseError, "invalid tag: #{tagged_name.inspect}" unless result
raise DuplicateNameError, "duplicate member name: #{result[:name].inspect}" if key?(result[:name])

super(name, TJSON::Parser.value(value))
type = TJSON::DataType.parse(result[:tag])
super(result[:name], type.convert(value))
end
end
end
Loading

0 comments on commit 64e3e6e

Please sign in to comment.