Permalink
Browse files

initial commit

  • Loading branch information...
0 parents commit 4e4d47c5e849084d5dc92bf483ee1eb648e1bc58 @joshbuddy committed Feb 1, 2012
5 .gitignore
@@ -0,0 +1,5 @@
+Gemfile.lock
+.yardoc
+doc
+Gemfile.lock
+.DS_Store
4 Gemfile
@@ -0,0 +1,4 @@
+source :rubygems
+
+gemspec
+gem 'hashie', :git => 'https://github.com/intridea/hashie.git'
167 README.md
@@ -0,0 +1,167 @@
+# Data bindings
+
+There are many ways to represent data. For instance, XML, JSON and YAML are all very similar while having different representations.
+Data bindings attempts to unify these various representations by allowing the creation of representation-free schemas which can be used to valiate a document. As well,
+it provides adapters to normalize access across these various types.
+
+Data bindings has four central concepts. *Adapters* provide normal access independent of representation. *Readers* allow you to define adapter-independent ways of reading data. *Writers* allows you define adapter-independent ways of writing data.*Validations* allow you to define a schema for your document.
+
+## 5 minute demo
+
+Start by loading from a JSON object
+
+ a = DataBindings.from_json('{"name":"Proust","books":[{"published":1913,"title":"Swan\'s Way"},{"published":1923,"title":"The Prisoner"}]}')
+
+(You could also load from YAML, XML BSON, etc by using `#from_yaml`, `#from_xml`, `#from_bson` and so forth)
+
+We can go ahead and access that like we nomrally would
+
+ a[:name]
+ # "proust"
+ a[:name][0][:title]
+ # "Swan's Way"
+
+Great, now let's get a validated copy of that object
+
+ b = a.bind {
+ property :name, String
+ property :books, [] {
+ property :published, Integer
+ property :title, String
+ }
+ }
+
+Is it okay?
+
+ b.valid?
+ # => true
+
+How about we represent it in YAML!
+
+ b.convert_to_yaml
+ # => "---\nname: Proust\nbooks:\n- published: 1913\n title: Swan's Way\n- published: 1923\n title: The Prisoner\n"
+
+Or, right out to a YAML file
+
+ b.convert_to_file(:yaml, "/tmp/proust.yaml")
+
+And load it back
+
+ from_file = DataBindings.from_yaml_file("/tmp/proust.yaml")
+ from_file.bind(a) # Use the binding from above
+
+We can also define the types independently so that we can associate them with Ruby constructors later.
+
+ DataBindings.type(:book) {
+ property :published, Integer
+ property :title, String
+ }
+
+ DataBindings.type(:person) {
+ property :name
+ property :books, [:book]
+ }
+
+ proust = DataBindings.from_yaml_file('/tmp/proust.yaml').bind(:person)
+ p proust[:name]
+ # => "Proust"
+ p proust[:books][1]
+ # => {"published"=>1923, "title"=>"The Prisoner"}
+
+Maybe we also want to create a Ruby object out of person, let's do that.
+
+ class Person
+ attr_reader :name, :books
+
+ def initialize(name, books)
+ @name = name
+ @books = books
+ end
+
+ def proust?
+ name.downcase == 'proust'
+ end
+ end
+
+ class Book
+ attr_reader :published, :title
+
+ def initialize(published, title)
+ @published, @title = published, title
+ end
+
+ def published_before?(year)
+ published < year
+ end
+ end
+
+ DataBindings.for_native(:person) { |attrs| Person.new(attrs[:name], attrs[:books]) }
+ DataBindings.for_native(:book) { |attrs| Book.new(attrs[:published], attrs[:title]) }
+
+ proust = DataBindings.from_yaml_file('/tmp/proust.yaml').bind!(:person).to_native
+ proust.proust?
+ # => true
+ proust.books[0].published_before?(2011)
+ # => true
+ proust.books[0].published_before?(1800)
+ # => false
+
+## Adapters
+
+Adapters have a simple contract. They must be a module. They must define a method #from_* where * is a type. For example, the JSONAdapter provides `#from_json`. They must also provide a singleton method #construct that can serialize an object into it's target representation. They may provide other methods to your base generator; they are included into it and thus can access any of it's internals. They are typically expected to return a ruby hash or array. For instance:
+
+ a = DataBindings.from_json('{"Hello":"World"}')
+ # => {"Hello"=>"World"}
+ a.class
+ # => DataBindings::Adapters::Ruby::RubyObjectAdapter
+
+## Binding
+
+Bindings provide a mechanism to validate certain properties of a Hash.
+
+To create a type, define it from your generator. For example:
+
+ DataBindings.type(:person) do
+ property :name, String
+ property :age, Integer
+ end
+
+Would define a type for `:person`. This object would have two properties `name` and `age`. The types available are String, Integer, Float, DataBindings::Boolean. As well, you can refer to any of the types you've defined previously. You can refer to an implicit array of values by putting the type in `[]`. For example, you could have
+
+ DataBindings.type(:person) do
+ property :name, String
+ property :age, Integer
+ property :lottery_numbers, [Integer]
+ end
+
+## Readers
+
+Readers provide an adapter-indepedent way of reading data from other sources. By default, we are also dealing with a String representation of the data. For instance:
+
+ DataBindings.from_json('{"Hello":"World"}')
+
+would create a JSON representation. You could provide file access by adding a `file` reader.
+
+ DataBindings.reader(:file) { |f| File.read(f) }
+
+Now, we could load the above JSON from disk by using
+
+ DataBindings.from_json_file('/tmp/file.json')
+
+The `#from_json_file` method is synthesized into your generator by adding a `:file` reader. By default, there are readers for files, io, and http.
+
+## Writers
+
+Writers provide an adapter-indepedent way of writing data to other sources. By default, we emit our representation of the data as a String. For instance:
+
+ DataBindings.from_ruby({"Hello" => "World"}).convert_to_yaml
+
+would create a YAML representation. You could provide file writing by adding a `file` writer.
+
+ DataBindings.reader(:file) { |obj, f| File.open(f, 'w') { |h| h << obj } }
+
+Now, if you wanted to write the above JSON to disk as YAML, you could do the following:
+
+ DataBindings.from_ruby({"Hello" => "World"}).convert_to_file(:yaml, "/tmp/out.yaml")
+
+The `#convert_to_file` method that would be synthesized into your generator. By default, there are writers for files, io, and http.
13 Rakefile
@@ -0,0 +1,13 @@
+require "bundler/gem_tasks"
+require 'rake/testtask'
+require 'yard'
+
+task :test do
+ Rake::TestTask.new do |t|
+ Dir['test/**/*_test.rb'].each{|f| require File.expand_path(f)}
+ end
+end
+
+YARD::Rake::YardocTask.new do |t|
+ t.files = ['lib/**/*.rb'] # optional
+end
35 data_bindings.gemspec
@@ -0,0 +1,35 @@
+# -*- encoding: utf-8 -*-
+$:.push File.expand_path("../lib", __FILE__)
+require "data_bindings/version"
+
+Gem::Specification.new do |s|
+ s.name = "data_bindings"
+ s.version = DataBindings::VERSION
+ s.authors = ["Joshual Hull"]
+ s.email = ["joshbuddy@gmail.com"]
+ s.homepage = "http://github.com/joshbuddy/data_bindings"
+ s.summary = %q{Bind data to and from things}
+ s.description = %q{Bind data to and from things.}
+
+ s.rubyforge_project = "data_bindings"
+
+ s.files = `git ls-files`.split("\n")
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
+ s.require_paths = ["lib"]
+
+ s.add_runtime_dependency 'hashie', '= 2.0.0.beta'
+
+ # specify any dependencies here; for example:
+ s.add_development_dependency 'bson'
+ s.add_development_dependency 'multi_json'
+ s.add_development_dependency 'nokogiri'
+ s.add_development_dependency 'builder'
+ s.add_development_dependency 'httparty'
+ s.add_development_dependency "minitest"
+ s.add_development_dependency "rake"
+ s.add_development_dependency "fakeweb"
+ s.add_development_dependency "yard"
+ s.add_development_dependency "redcarpet"
+ s.add_development_dependency "tnetstring"
+end
48 lib/data_bindings.rb
@@ -0,0 +1,48 @@
+require 'hashie'
+
+require 'data_bindings/util'
+require 'data_bindings/generator'
+require 'data_bindings/version'
+require 'data_bindings/converters'
+require 'data_bindings/bound'
+require 'data_bindings/unbound'
+require 'data_bindings/adapters'
+
+# From https://github.com/marcandre/backports
+module Kernel
+ # Standard in ruby 1.9. See official documentation[http://ruby-doc.org/core-1.9/classes/Object.html]
+ def define_singleton_method(*args, &block)
+ class << self
+ self
+ end.send(:define_method, *args, &block)
+ end unless method_defined? :define_singleton_method
+end
+
+# Top-level constant for DataBindings
+module DataBindings
+
+ class << self
+ # Sends all methods calls to DefaultGenerator
+ def method_missing(m, *args, &blk)
+ DefaultGeneratorInstance.send(:build!)
+ DefaultGeneratorInstance.send(m, *args, &blk)
+ end
+
+ def true_boolean?(el)
+ el == true or el == 'true' or el == 1 or el == '1' or el == 'yes'
+ end
+
+ def primitive_value?(val)
+ case val
+ when Integer, Float, true, false, String, Symbol, nil
+ true
+ else
+ false
+ end
+ end
+ end
+
+ # Generator instance used by default when you make a call to DataBindings. This can act as a singleton, so, if you want your own
+ # generator, create an instance of it
+ DefaultGeneratorInstance = DefaultGenerator.new
+end
12 lib/data_bindings/adapters.rb
@@ -0,0 +1,12 @@
+module DataBindings
+ module Adapters
+ autoload :BSON, 'data_bindings/adapters/bson'
+ autoload :JSON, 'data_bindings/adapters/json'
+ autoload :Native, 'data_bindings/adapters/native'
+ autoload :Ruby, 'data_bindings/adapters/ruby'
+ autoload :YAML, 'data_bindings/adapters/yaml'
+ autoload :Params, 'data_bindings/adapters/params'
+ autoload :XML, 'data_bindings/adapters/xml'
+ autoload :TNetstring, 'data_bindings/adapters/tnetstring'
+ end
+end
32 lib/data_bindings/adapters/bson.rb
@@ -0,0 +1,32 @@
+module DataBindings
+ module Adapters
+ module BSON
+ include Ruby
+ include DataBindings::GemRequirement
+
+ # Constructs a wrapped object from a JSON string
+ # @param [String] str The JSON object
+ # @return [RubyObjectAdapter, RubyArrayAdapter] The wrapped object
+ def from_bson(str)
+ from_ruby(::BSON.deserialize(str.unpack("C*")))
+ end
+ gentle_require_gem :from_bson, 'bson'
+
+ module Convert
+ include ConverterHelper
+ include DataBindings::GemRequirement
+
+ # Creates a String repsentation of a Ruby Hash or Array.
+ # @param [Generator] generator The generator that invokes this constructor
+ # @param [Symbol] name The name of the binding used on this object
+ # @param [Array, Hash] obj The object to be represented in JSON
+ # @return [String] The JSON representation of this object
+ def force_convert_to_bson
+ ::BSON.serialize(self).to_s
+ end
+ gentle_require_gem :force_convert_to_bson, 'bson'
+ standard_converter :convert_to_bson
+ end
+ end
+ end
+end
32 lib/data_bindings/adapters/json.rb
@@ -0,0 +1,32 @@
+module DataBindings
+ module Adapters
+ module JSON
+ include Ruby
+ include DataBindings::GemRequirement
+
+ # Constructs a wrapped object from a JSON string
+ # @param [String] str The JSON object
+ # @return [RubyObjectAdapter, RubyArrayAdapter] The wrapped object
+ def from_json(str)
+ from_ruby(MultiJson.decode(str))
+ end
+ gentle_require_gem :from_json, 'multi_json'
+
+ module Convert
+ include ConverterHelper
+ include DataBindings::GemRequirement
+
+ # Creates a String repsentation of a Ruby Hash or Array.
+ # @param [Generator] generator The generator that invokes this constructor
+ # @param [Symbol] name The name of the binding used on this object
+ # @param [Array, Hash] obj The object to be represented in JSON
+ # @return [String] The JSON representation of this object
+ def force_convert_to_json
+ MultiJson.encode(self)
+ end
+ gentle_require_gem :force_convert_to_json, 'multi_json'
+ standard_converter :convert_to_json
+ end
+ end
+ end
+end
50 lib/data_bindings/adapters/native.rb
@@ -0,0 +1,50 @@
+module DataBindings
+ module Adapters
+ module Native
+ # Constructs a wrapped object from a native Ruby object. This object is expected
+ # to respond to calls similar to those defined by #attr_accessor
+ # @param [Object] obj The object to be wrapped
+ # @return [NativeArrayAdapter, NativeObjectAdapter] The wrapped object
+ def from_native(obj)
+ binding_class(NativeAdapter).new(self, obj)
+ end
+
+ class NativeAdapter
+ include Unbound
+
+ def initialize(generator, object)
+ @generator, @object = generator, object
+ end
+
+ def pre_convert
+ raise DataBindings::UnboundError unless @name
+ end
+
+ def type
+ @object.is_a?(Array) ? :array : :hash
+ end
+
+ def [](idx)
+ val = @object.respond_to?(:[]) ? @object[idx] : @object.send(idx)
+ if DataBindings.primitive_value?(val)
+ val
+ else
+ binding_class(NativeAdapter).new(@generator, val)
+ end
+ end
+
+ def []=(idx, value)
+ @object.respond_to?(:[]=) ? @object[idx] = value : @object.send("#{idx}=", value)
+ end
+
+ def key?(name)
+ @object.respond_to?(name)
+ end
+
+ def to_hash
+ raise UnboundError
+ end
+ end
+ end
+ end
+end
91 lib/data_bindings/adapters/params.rb
@@ -0,0 +1,91 @@
+require 'cgi'
+
+module DataBindings
+ module Adapters
+ module Params
+ include Ruby
+
+ def from_params(str)
+ from_ruby( parse_nested_query(str) )
+ end
+
+
+ def parse_nested_query(qs, d = nil)
+ params = {}
+
+ (qs || '').split(d ? /[#{d}] */n : /[&;] */n).each do |p|
+ k, v = p.split('=', 2).map { |s| CGI::unescape(s) }
+ normalize_params(params, k, v)
+ end
+
+ return params
+ end
+
+ private
+ def normalize_params(params, name, v = nil)
+ name =~ %r(\A[\[\]]*([^\[\]]+)\]*)
+ k = $1 || ''
+ after = $' || ''
+
+ return if k.empty?
+
+ if after == ""
+ params[k] = v
+ elsif after == "[]"
+ params[k] ||= []
+ raise TypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array)
+ params[k] << v
+ elsif after =~ %r(^\[\]\[([^\[\]]+)\]$) || after =~ %r(^\[\](.+)$)
+ child_key = $1
+ params[k] ||= []
+ raise TypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array)
+ if params[k].last.is_a?(Hash) && !params[k].last.key?(child_key)
+ normalize_params(params[k].last, child_key, v)
+ else
+ params[k] << normalize_params({}, child_key, v)
+ end
+ else
+ params[k] ||= {}
+ raise TypeError, "expected Hash (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Hash)
+ params[k] = normalize_params(params[k], after, v)
+ end
+
+ return params
+ end
+
+ module Convert
+ include ConverterHelper
+
+ # Creates a String repsentation of a Ruby Hash or Array.
+ # @param [Generator] generator The generator that invokes this constructor
+ # @param [Symbol] name The name of the binding used on this object
+ # @param [Array, Hash] obj The object to be represented in JSON
+ # @return [String] The JSON representation of this object
+ def force_convert_to_params
+ build_nested_query(to_hash)
+ end
+ standard_converter :convert_to_params
+
+ private
+
+ def build_nested_query(value, prefix = nil)
+ case value
+ when Array
+ index = 0
+ value.map { |v|
+ query_string = build_nested_query(v, prefix ? "#{prefix}[#{index}]" : index)
+ index += 1
+ query_string
+ }.join("&")
+ when Hash
+ value.map { |k, v|
+ build_nested_query(v, prefix ? "#{prefix}[#{CGI::escape(k)}]" : CGI::escape(k))
+ }.join("&")
+ else
+ "#{prefix}=#{CGI::escape(value.to_s)}"
+ end
+ end
+ end
+ end
+ end
+end
44 lib/data_bindings/adapters/ruby.rb
@@ -0,0 +1,44 @@
+module DataBindings
+ module Adapters
+ module Ruby
+
+ # Constructs a wrapped object from an Array or Hash
+ # @param [Array, Hash] obj The Ruby array or hash
+ # @return [RubyObjectAdapter, RubyArrayAdapter] The wrapped object
+ def from_ruby(obj)
+ case obj
+ when Array then from_ruby_array(obj)
+ when Hash then from_ruby_hash(obj)
+ else obj
+ end
+ end
+
+ def from_ruby_hash(h)
+ binding_class(RubyObjectAdapter).new(self, h)
+ end
+ alias_method :from_ruby_object, :from_ruby_hash
+
+ def from_ruby_array(a)
+ binding_class(RubyArrayAdapter).new(self, a)
+ end
+
+ class RubyArrayAdapter < Array
+ include Unbound
+
+ def initialize(generator, o)
+ @generator = generator
+ super o
+ end
+ end
+
+ class RubyObjectAdapter < IndifferentHash
+ include Unbound
+
+ def initialize(generator, o)
+ @generator = generator
+ replace o
+ end
+ end
+ end
+ end
+end
32 lib/data_bindings/adapters/tnetstring.rb
@@ -0,0 +1,32 @@
+module DataBindings
+ module Adapters
+ module TNetstring
+ include Ruby
+ include DataBindings::GemRequirement
+
+ # Constructs a wrapped object from a Tnetstring
+ # @param [String] str The Tnetstring object
+ # @return [RubyObjectAdapter, RubyArrayAdapter] The wrapped object
+ def from_tnetstring(str)
+ from_ruby(::TNetstring.parse(str)[0])
+ end
+ gentle_require_gem :from_tnetstring, 'tnetstring'
+
+ module Convert
+ include ConverterHelper
+ include DataBindings::GemRequirement
+
+ # Creates a String repsentation of a Ruby Hash or Array.
+ # @param [Generator] generator The generator that invokes this constructor
+ # @param [Symbol] name The name of the binding used on this object
+ # @param [Array, Hash] obj The object to be represented in JSON
+ # @return [String] The Tnetstring representation of this object
+ def force_convert_to_tnetstring
+ ::TNetstring.dump(self)
+ end
+ gentle_require_gem :force_convert_to_tnetstring, 'tnetstring'
+ standard_converter :convert_to_tnetstring
+ end
+ end
+ end
+end
74 lib/data_bindings/adapters/xml.rb
@@ -0,0 +1,74 @@
+module DataBindings
+ module Adapters
+ module XML
+ include Ruby
+ include DataBindings::GemRequirement
+
+ # Constructs a wrapped object from a JSON string
+ # @param [String] str The JSON object
+ # @return [RubyObjectAdapter, RubyArrayAdapter] The wrapped object
+ def from_xml(str)
+ from_ruby(from_xml_obj(Nokogiri::XML(str)))
+ end
+ gentle_require_gem :from_xml, 'nokogiri'
+
+ def from_xml_obj(o)
+ case o.type
+ when Nokogiri::XML::Node::DOCUMENT_NODE
+ from_xml_obj(o.children[0])
+ when Nokogiri::XML::Node::TEXT_NODE
+ o.text
+ when Nokogiri::XML::Node::ELEMENT_NODE
+ if o.children.size == 1 and o.children[0].text?
+ from_xml_obj(o.children[0])
+ elsif o.children[0].name == '0'
+ o.children.map{ |c| from_xml_obj(c) }
+ else
+ Hash[o.children.map { |n| [n.name, from_xml_obj(n)] }]
+ end
+ end
+ end
+ gentle_require_gem :from_xml_obj, 'nokogiri'
+
+ # Creates a String repsentation of a Ruby Hash or Array.
+ # @param [Generator] generator The generator that invokes this constructor
+ # @param [Symbol] name The name of the binding used on this object
+ # @param [Array, Hash] obj The object to be represented in JSON
+ # @return [String] The JSON representation of this object
+
+ module Convert
+ include DataBindings::GemRequirement
+ include ConverterHelper
+
+ def force_convert_to_xml
+ Convert.construct(@generator, @name, self, @binding_block)
+ end
+ gentle_require_gem :force_convert_to_xml, 'builder'
+ standard_converter :convert_to_xml
+
+ def self.construct(generator, name, obj, builder = nil)
+ root = builder.nil?
+ builder ||= Builder::XmlMarkup.new
+ builder.instruct!(:xml, :encoding => "UTF-8") if root
+ case obj
+ when Array
+ builder.__send__(name || "doc") do |b|
+ obj.each_with_index(o, i)
+ construct(generator, i.to_s, o, b)
+ end
+ when Hash
+ builder.__send__(name || "doc") do |b|
+ obj.each do |k, v|
+ construct(generator, k, v, b)
+ end
+ end
+ else
+ builder.__send__(name, obj)
+ end
+ builder.target! if root
+ end
+
+ end
+ end
+ end
+end
26 lib/data_bindings/adapters/yaml.rb
@@ -0,0 +1,26 @@
+require 'yaml'
+
+module DataBindings
+ module Adapters
+ module YAML
+ include Ruby
+
+ def from_yaml(str)
+ from_ruby(::YAML::load(str))
+ end
+
+ def from_yaml_file(f)
+ from_ruby(::YAML::load_file(f))
+ end
+
+ module Convert
+ include ConverterHelper
+
+ def force_convert_to_yaml
+ ::YAML::dump(self.to_hash)
+ end
+ standard_converter :convert_to_yaml
+ end
+ end
+ end
+end
323 lib/data_bindings/bound.rb
@@ -0,0 +1,323 @@
+module DataBindings
+ module Bound
+ ValidationError = Class.new(RuntimeError)
+ NoBindingName = Class.new(RuntimeError)
+
+ class Errors < DataBindings::IndifferentHash
+ attr_accessor :base
+
+ def join(st = nil)
+ (base ? [base].concat(values) : values).join(st)
+ end
+
+ def valid?
+ base.nil? && empty?
+ end
+
+ def clear
+ super
+ @base = nil
+ end
+ end
+
+ #include DataBindings::WriterInterceptor
+
+ attr_reader :errors, :source, :name, :generator
+
+ def valid?
+ calculate_validness
+ errors.valid?
+ end
+
+ def calculate_validness
+ if @last_hash.nil? || @last_hash != hash
+ @from = self if @last_hash
+ errors.clear
+ validate
+ @last_hash = hash
+ end
+ end
+
+ def valid!
+ valid? or raise FailedValidation.new("Object was invalid with the following errors: #{errors.join(", ")}", errors, source)
+ self
+ end
+
+ def pre_convert
+ valid!
+ end
+
+ def cast_element(lookup_name, source, type, opts = nil, &blk)
+ name = get_parameter_name(lookup_name, source, opts)
+ el = name && source[name]
+ raise_on_error = opts && opts.key?(:raise_on_error) ? opts[:raise_on_error] : false
+ allow_nil = opts && opts.key?(:allow_nil) ? opts[:allow_nil] : false
+ el ||= opts[:default] if opts && opts.key?(:default)
+
+ if el.nil? && name.nil?
+ @errors[lookup_name] = generate_error("not found", raise_on_error)
+ nil
+ else
+ new_el = if type.nil?
+ # anything goes
+ case el
+ when Array, Hash
+ blk ? register_sub(name, @generator.from_ruby(el).bind(&blk), raise_on_error) : el
+ else
+ el
+ end
+ elsif type == String
+ @errors[lookup_name] = generate_error("was not a String", raise_on_error) unless el.is_a?(String)
+ el
+ elsif type == Integer
+ begin
+ Integer(el)
+ rescue ArgumentError, TypeError
+ @errors[lookup_name] = generate_error("was not an Integer", raise_on_error)
+ el
+ end
+ elsif type == Float
+ begin
+ Float(el)
+ rescue ArgumentError, TypeError
+ @errors[lookup_name] = generate_error("was not a Float", raise_on_error)
+ el
+ end
+ elsif Array === type
+ if el.is_a?(Array)
+ @errors[lookup_name] = generate_error("did not match the length", raise_on_error) if opts && opts[:length] && !(opts[:length] === el.size)
+ if type.first
+ register_sub(name, @generator.from_ruby(el).bind_array { all_elements type.first }, raise_on_error)
+ elsif blk
+ register_sub(name, @generator.from_ruby(el).bind_array { all_elements &blk }, raise_on_error)
+ else
+ el
+ end
+ else
+ @errors[lookup_name] = generate_error("was not an Array", raise_on_error)
+ el
+ end
+ elsif type == :boolean
+ if allow_nil
+ el.nil? ? nil : DataBindings.true_boolean?(el)
+ else
+ DataBindings.true_boolean?(el)
+ end
+ elsif Symbol === type
+ if el.is_a?(Hash)
+ register_sub(name, @generator.from_ruby(el).bind(type), raise_on_error)
+ else
+ @errors[lookup_name] = generate_error("was not a Hash", raise_on_error)
+ el
+ end
+ else
+ raise "Unknown type #{type.inspect}"
+ end
+ if inclusion = opts && opts[:in]
+ @errors[lookup_name] = generate_error("was not included in #{inclusion.inspect}", raise_on_error) unless inclusion.include?(new_el)
+ end
+ @errors[lookup_name] = generate_error("was nil", raise_on_error) if new_el.nil? && !allow_nil
+ new_el
+ end
+ end
+
+ private
+
+ def dump_val(val)
+ if val.respond_to?(:to_hash)
+ val.to_hash
+ elsif val.respond_to?(:to_ary)
+ val.to_ary
+ else
+ val
+ end
+ end
+
+ def convert_target
+ self
+ end
+
+ def generate_error(str, raise_on_error)
+ raise_on_error ? raise(ValidationError, str) : str
+ end
+
+ def register_sub(name, sub, raise_on_error)
+ unless sub.valid?
+ @errors[name] = generate_error(sub.errors.to_s, raise_on_error)
+ end
+ sub
+ end
+
+ def validate
+ reset_validation_state
+ run_validation
+ enforce_strictness if @strict
+ end
+
+ def init_bound(generator, source, name, opts, validator)
+ @errors, @generator, @source, @name, @opts, @validator = Errors.new, generator, source, name, opts, validator
+ @from = @source
+ @strict = opts && opts.key?(:strict) ? opts[:strict] : generator.strict?
+ reset_validation_state
+ valid?
+ end
+
+ class BoundObject < DataBindings::IndifferentHash
+ include Bound
+
+ def initialize(generator, array_expected, source, name, opts, &blk)
+ raise BindingMismatch if array_expected
+ init_bound(generator, source, name, opts, blk)
+ end
+
+ def to_hash
+ keys.inject(DataBindings::IndifferentHash.new) { |h, k|
+ val = self[k]
+ h[k] = dump_val(val)
+ h
+ }
+ end
+
+ def to_native
+ valid!
+ data = inject(IndifferentHash.new) { |h, (k, v)|
+ h[k] = v.respond_to?(:to_native) ? v.to_native : v
+ h
+ }
+ if constructor = generator.native_constructors[name]
+ o = constructor[data.to_hash]
+ else
+ OpenStruct.new(data)
+ end
+ end
+
+ def property(name, type = nil, opts = nil, &blk)
+ type, opts = nil, type if type.is_a?(Hash)
+ self[name] = cast_element(name, @from, type, opts, &blk)
+ end
+
+ def required(name, type = nil, opts = nil, &blk)
+ type, opts = nil, type if type.is_a?(Hash)
+ opts ||= {}
+ opts[:allow_nil] = false
+ property(name, type, opts, &blk)
+ end
+
+ def optional(name, type = nil, opts = nil, &blk)
+ type, opts = nil, type if type.is_a?(Hash)
+ opts ||= {}
+ opts[:allow_nil] = true
+ property(name, type, opts, &blk)
+ end
+
+ def all_properties(type = nil, opts = nil)
+ type, opts = nil, type if type.is_a?(Hash)
+ @from.keys.each do |key|
+ property key, type, opts
+ end
+ end
+
+ def enforce_strictness
+ @errors.base = "hasn't been fully matched" unless size == @from.size
+ end
+
+ def copy_source
+ replace @source
+ end
+
+ private
+
+ def get_parameter_name(name, source, opts)
+ name = if opts && opts[:alias]
+ aliases = Array(opts[:alias])
+ index = aliases.index {|k| source.key?(k) }
+ index ? aliases[index] : name
+ else
+ name
+ end
+ source.key?(name) ? name : nil
+ end
+
+ def reset_validation_state
+ errors.clear
+ end
+
+ def run_validation
+ instance_eval(&@validator)
+ end
+ end
+
+ class BoundArray < Array
+ include Bound
+
+ def initialize(generator, array_expected, source, name, opts, &blk)
+ raise BindingMismatch unless array_expected
+ init_bound(generator, source, name, opts, blk)
+ end
+
+ def to_ary
+ self.inject([]) { |a, v|
+ a << dump_val(v)
+ }
+ end
+
+ def to_native
+ valid!
+ inject([]) {|a, el| a << (el.respond_to?(:to_native) ? el.to_native : el); a}
+ end
+
+ def elements(size = nil, type = nil, opts = nil, &blk)
+ if size.nil? || size.respond_to?(:to_int)
+ size ||= source.size
+ # consume all
+ (@pos...(@pos+size)).each do |i|
+ self[@pos] = cast_element(@pos, @from, type, opts, &blk)
+ @pos += 1
+ end
+ elsif size.respond_to?(:min) && size.respond_to?(:max)
+ original_pos = @pos
+ while (@pos - original_pos) <= size.max
+ begin
+ opts ||= {}
+ opts[:raise_on_error] = @pos >= size.min
+ self[@pos] = cast_element(@pos, @from, type, opts, &blk)
+ rescue ValidationError
+ break
+ end
+ @pos += 1
+ end
+ else
+ raise "Size isn't understood: #{size.inspect}"
+ end
+ end
+
+ def copy_source
+ replace @source
+ end
+
+ def enforce_strictness
+ @errors.base = "hasn't been fully matched" unless @pos.succ == source.size
+ end
+
+ def all_elements(type = nil, &blk)
+ elements(nil, type, &blk)
+ end
+
+ private
+
+ def get_parameter_name(name, source, opts)
+ source.at(name) ? name : nil
+ end
+
+ def reset_validation_state
+ @pos = 0
+ errors.clear
+ end
+
+ def run_validation
+ errors.base = "didn't match legnth #{@opts[:length]}" if @opts && @opts[:length] && !(@opts[:length] === size)
+ instance_eval(&@validator)
+ end
+ end
+ end
+end
83 lib/data_bindings/converters.rb
@@ -0,0 +1,83 @@
+module DataBindings
+
+ # Exception raised by invalid #*_http calls.
+ class HttpError < RuntimeError
+ # The HTTParty::Response object underlying this exception
+ attr_reader :response
+
+ def initialize(m, response)
+ super m
+ @response = response
+ end
+ end
+
+ # This defines the default readers used.
+ module Readers
+ include GemRequirement
+
+ # Takes an IO object and reads it's contents.
+ # @param [IO] i The IO object to read from
+ # @return The contents of the IO object
+ def io(i)
+ i.rewind
+ i.read
+ end
+
+ # Takes a file path and returns it's contents.
+ # @param [String] path The file path
+ # @return The contents of the file
+ def file(path)
+ File.read(path)
+ end
+
+ # Takes a URL and returns it's contents. Uses HTTPParty underlyingly.
+ # @param [String] url The URL to request from
+ # @param [Hash] opts The options to pass in to HTTPParty
+ # @return The body of the response from the URL as a String
+ # @see https://github.com/jnunemaker/httparty
+ def http(url, opts = {})
+ method = opts[:method] || :get
+ response = HTTParty.send(method, url, opts)
+ if (200..299).include?(response.code)
+ response.body
+ else
+ raise HttpError.new("Bad response: #{response.code} #{response.body}", response)
+ end
+ end
+ gentle_require_gem :http, 'httparty'
+ end
+
+ # This defines the default writers used.
+ module Writers
+ include GemRequirement
+
+ # Takes data and an IO object and writes it's contents to it.
+ # @param [String] data The data to be written
+ # @param [IO] i The IO object to write to
+ def io(data, io)
+ io.write(obj)
+ end
+
+ # Takes data and a file path and writes it's contents to it.
+ # @param [String] data The data to be written
+ # @param [String] path The IO object to write to
+ def file(data, path)
+ File.open(path, 'w') { |f| f << data }
+ end
+
+ # Takes a URL and posts the contents of your data to it. Uses HTTPParty underlyingly.
+ # @param [String] data The data to send to
+ # @param [String] url The URL to send your request to
+ # @param [Hash] opts The options to pass in to HTTPParty
+ # @see https://github.com/jnunemaker/httparty
+ def http(data, url, opts = {})
+ method = opts[:method] || :post
+ opts[:data] = data
+ response = HTTParty.send(method, url, opts)
+ unless (200..299).include?(response.code)
+ raise HttpError.new("Bad response: #{response.code} #{response.body}", response)
+ end
+ end
+ gentle_require_gem :http, 'httparty'
+ end
+end
140 lib/data_bindings/generator.rb
@@ -0,0 +1,140 @@
+module DataBindings
+ # This is the class the handles registering readers, writers, adapters and types.
+ class Generator
+ # Enable/disable strict mode
+ attr_accessor :strict
+ alias_method :strict?, :strict
+
+ def initialize
+ reset!
+ end
+
+ # Defines an object type
+ # @param [Symbol] name The name of the type
+ # @see https://github.com/joshbuddy/data_bindings/wiki/Types
+ def type(name, &blk)
+ @types[name] = blk
+ end
+
+ # Retrieves an object type
+ # @param [Symbol] name The name of the type
+ # @return [Proc] The body of the type
+ def get_type(name)
+ @types[name]
+ end
+
+ def binding_class(cls)
+ mod = @writer_module
+ @binding_classes[cls] ||= begin
+ Class.new(cls) do
+ include mod
+ end
+ end
+ end
+
+ # Retrieves an adapter
+ # @param [Symbol] name The name of the adapter
+ # @return [Object] The adapter
+ def get_adapter(name)
+ @adapter_classes[name] or raise UnknownAdapterError, "Could not find adapter #{name.inspect}"
+ end
+
+ # Defines a reader
+ # @param [Symbol] name The name of the reader
+ # @yield [*Object] All arguments passed to the method used to invoke this reader
+ def reader(name, &blk)
+ @reader_module.define_singleton_method(name, &blk)
+ @build = false
+ end
+
+ # Defines a writer
+ # @param [Symbol] name The name of the writer
+ # @yield [*Object] All arguments passed to the method used to invoke this writer
+ def writer(name, &blk)
+ @writer_module.define_singleton_method(name, &blk)
+ end
+
+ # Passes off writing of an object through a specific writer.
+ # @param [Symbol] method_name The method name to be invoked on the writer
+ # @param [String] data The data to be written
+ def write(method_name, obj, *args, &blk)
+ @writer_module.send(method_name, obj, *args, &blk)
+ end
+
+ # Tests if a specific type of writer is supported
+ # @param [Symbol] name The name of the writer to test
+ # @return [Boolean]
+ def write_targets(name)
+ target, format = name.to_s.split(/_/, 2)
+ end
+
+ # Registers an adapter
+ # @param [Symbol] name The name of the adapter
+ # @param [Object] The adapter
+ def register(name, cls)
+ @adapters[name] = cls
+ @build = false
+ end
+
+ # Resets the generator to a blank state
+ def reset!
+ @reader_module = Module.new { extend Readers }
+ @writer_module = Module.new { extend Writers; include WritingInterceptor }
+ @strict = false
+ @types = {}
+ @adapters = {}
+ @adapter_classes = {}
+ @binding_classes = {}
+ end
+
+ # Defines a native constructor
+ # @param [Symbol] name The name of the type to create a constructor for
+ def for_native(name, &blk)
+ native_constructors[name] = blk
+ end
+
+ def native_constructors
+ @native_constructors ||= {}
+ end
+
+ private
+ # @api private
+ def build!
+ return if @built
+ @adapters.each do |name, cls|
+ unless @adapter_classes[name]
+ @adapter_classes[name] = cls.to_s.split('::').inject(Object) {|const, n| const.const_get(n)}
+ extend @adapter_classes[name]
+ @writer_module.send(:include, @adapter_classes[name]::Convert) if @adapter_classes[name].const_defined?(:Convert)
+ end
+ converter_methods = @reader_module.methods - Module.methods
+ converter_methods.each do |m|
+ method_name = :"from_#{name}_#{m}"
+ unless singleton_methods.include?(method_name)
+ define_singleton_method method_name do |*args|
+ out = @reader_module.send(m, *args)
+ send(:"from_#{name}", out)
+ end
+ end
+ end
+ end
+ @build = true
+ end
+ end
+
+ class DefaultGenerator < Generator
+ # Resets the generator to a blank state and installs the json, yaml, ruby and native
+ # adpaters
+ def reset!
+ super
+ register(:json, 'DataBindings::Adapters::JSON')
+ register(:yaml, 'DataBindings::Adapters::YAML')
+ register(:ruby, 'DataBindings::Adapters::Ruby')
+ register(:native, 'DataBindings::Adapters::Native')
+ register(:bson, 'DataBindings::Adapters::BSON')
+ register(:params, 'DataBindings::Adapters::Params')
+ register(:xml, 'DataBindings::Adapters::XML')
+ register(:tnetstring, 'DataBindings::Adapters::TNetstring')
+ end
+ end
+end
76 lib/data_bindings/unbound.rb
@@ -0,0 +1,76 @@
+module DataBindings
+ # Module that handles unbound objects
+ module Unbound
+
+ attr_reader :binding, :binding_name
+
+ def bind!(name = nil, &blk)
+ bind(name, &blk).valid!
+ end
+
+ def convert_target
+ bind { copy_source }
+ end
+
+ def bind(name = nil, opts = nil, &blk)
+ if name.is_a?(Unbound)
+ update_binding(name.binding_name, &name.binding)
+ else
+ name, opts = nil, name if name.is_a?(Hash) && opts.nil?
+ raise if name.nil? && blk.nil?
+ update_binding(name, &blk)
+ end
+ binding_class.new(@generator, @array, self, name, opts, &@binding)
+ end
+
+ def bind_array(type = nil, opts = nil, &blk)
+ type, opts = nil, type if type.is_a?(Hash) && opts.nil?
+ update_binding([], &blk)
+ binding_class.new(@generator, @array, self, nil, opts, &blk)
+ end
+
+ def type
+ if self.is_a?(Array)
+ :array
+ elsif self.is_a?(Hash)
+ :hash
+ else
+ raise
+ end
+ end
+
+ def hash?
+ type == :hash
+ end
+
+ def array?
+ type == :array
+ end
+
+ def to_native
+ array? ?
+ map{ |m| m.respond_to?(:to_native) ? m.to_native : m } :
+ OpenStruct.new(inject({}) {|h, (k, v)| v = @generator.from_ruby(v); h[k] = (v.respond_to?(:to_native) ? v.to_native : v); h})
+ end
+
+ def update_binding(name, &blk)
+ if name.is_a?(Array)
+ n = name.at(0)
+ @array = true
+ blk = proc { all_elements n }
+ name = nil
+ else
+ @array = false
+ end
+ @binding = @generator.get_type(name) || blk || raise(UnknownBindingError, "Unknown binding #{name.inspect}")
+ @name = name
+ end
+
+ def binding_class
+ case type
+ when :array then @generator.binding_class(Bound::BoundArray)
+ when :hash then @generator.binding_class(Bound::BoundObject)
+ end
+ end
+ end
+end
78 lib/data_bindings/util.rb
@@ -0,0 +1,78 @@
+module DataBindings
+ class FailedValidation < RuntimeError
+ attr_reader :errors, :original
+ def initialize(message, errors, original)
+ @errors, @original = errors, original
+ super message
+ end
+ end
+
+ UnboundError = Class.new(RuntimeError)
+ UnknownAdapterError = Class.new(RuntimeError)
+ UnknownBindingError = Class.new(RuntimeError)
+ BindingMismatch = Class.new(RuntimeError)
+
+ class IndifferentHash < Hash
+ include Hashie::Extensions::IndifferentAccess
+ end
+
+ module ConverterHelper
+ def self.included(m)
+ m.class_eval <<-EOT, __FILE__, __LINE__ +1
+ def self.standard_converter(m)
+ define_method(m) do
+ pre_convert if respond_to?(:pre_convert)
+ send(:"force_\#{m}")
+ end
+ end
+ EOT
+ end
+ end
+
+ module WritingInterceptor
+ def method_missing(m, *args, &blk)
+ if match = m.to_s.match(/^((?:force_)?convert_to_(?:[^_]+))_(.*)/)
+ self.class.class_eval <<-EOT, __FILE__, __LINE__ + 1
+ def #{m}(*args, &blk)
+ @generator.write(#{match[2].inspect}, send(#{match[1].inspect}, *args, &blk), *args, &blk)
+ end
+ EOT
+ send(m, *args, &blk)
+ else
+ super
+ end
+ end
+ end
+
+ module GemRequirement
+ def self.included(o)
+ o.extend ClassMethods
+ end
+
+ module ClassMethods
+ def gentle_require_gem(method, gem)
+ class_eval <<-EOT, __FILE__, __LINE__ + 1
+ alias_method :#{method}_without_gem, :#{method}
+ def #{method}(*args, &blk)
+ DataBindings::GemRequirement.gentle_require_gem #{gem.to_s.inspect}
+ class << self
+ self
+ end.instance_eval do
+ alias_method :#{method}, :#{method}_without_gem
+ end
+ #{method}(*args, &blk)
+ end
+ EOT
+ end
+ end
+
+ def self.gentle_require_gem(gem)
+ begin
+ require gem
+ rescue LoadError
+ warn "The `#{gem}' gem must be loadable"
+ exit 1
+ end
+ end
+ end
+end
3 lib/data_bindings/version.rb
@@ -0,0 +1,3 @@
+module DataBindings
+ VERSION = "0.0.1"
+end
55 test/array_test.rb
@@ -0,0 +1,55 @@
+require File.expand_path("../test_helper", __FILE__)
+
+describe "Data Bindings array" do
+ before do
+ DataBindings.reset!
+ end
+
+ describe "from bind" do
+ it "should validate a list of integers" do
+ a = DataBindings.from_json("[1,2,3]").bind([Integer])
+ assert a.valid?
+ assert a.errors.empty?
+ assert_equal [1, 2, 3], [a[0], a[1], a[2]]
+ a.unshift 'asd'
+ refute a.valid?
+ refute a.errors.empty?
+ end
+
+ it "should bind a list of complex things" do
+ DataBindings.type(:person) { property :name, String }
+ a = DataBindings.from_json('[{"name":"a"},{"name":"b"},{"name":"c"}]').bind([:person])
+ assert a.valid?
+ assert a.errors.empty?
+ assert_equal 3, a.size
+ assert_equal 'c', a[2][:name]
+ a.unshift 'asd'
+ refute a.valid?
+ refute a.errors.empty?
+ end
+
+ it "should validate a list" do
+ assert DataBindings.from_json("[1,2,3]").bind([]).valid?
+ refute DataBindings.from_json("[1,2,3]").bind([], :length => 2).valid?
+ assert_raises(DataBindings::BindingMismatch) { DataBindings.from_json("{}").bind([]) }
+ end
+ end
+
+ describe "from within a bind" do
+ it "should validate a list of integers" do
+ a = DataBindings.from_json('{"a":[1,2,3]}').bind { property :a, [Integer] }
+ assert a.valid?
+ assert a.errors.empty?
+ assert_equal [1, 2, 3], [a[:a][0], a[:a][1], a[:a][2]]
+ a[:a].unshift 'asd'
+ refute a.valid?
+ refute a.errors.empty?
+ end
+
+ it "should validate a list" do
+ assert DataBindings.from_json('{"a":[1,2,3]}').bind { property :a, [Integer], :length => 3 }.valid?
+ refute DataBindings.from_json('{"a":[1,2,3,4]}').bind { property :a, [Integer], :length => 3 }.valid?
+ end
+
+ end
+end
23 test/bson_test.rb
@@ -0,0 +1,23 @@
+require File.expand_path("../test_helper", __FILE__)
+
+describe "Data Bindings bson" do
+ describe "bson parsing" do
+ it "should parse bson" do
+ a = DataBindings.from_bson(BSON.serialize(:author => 'siggy', :title => 'bible')).bind { property :author; property :title }
+ assert a.valid?
+ assert_equal "siggy", a[:author]
+ end
+ end
+
+ describe "bson generation" do
+ it "should generate bson" do
+ a = DataBindings.from_ruby('author' => 'siggy',"title" => 'koran').bind { property :author; property :title }
+ assert a.valid?
+ valid_bson_representations = [
+ "(\x00\x00\x00\x02author\x00\x06\x00\x00\x00siggy\x00\x02title\x00\x06\x00\x00\x00koran\x00\x00",
+ "(\x00\x00\x00\x02title\x00\x06\x00\x00\x00koran\x00\x02author\x00\x06\x00\x00\x00siggy\x00\x00"
+ ]
+ assert valid_bson_representations.include?( a.convert_to_bson )
+ end
+ end
+end
36 test/converter_test.rb
@@ -0,0 +1,36 @@
+require File.expand_path("../test_helper", __FILE__)
+
+describe "Data Bindings" do
+ before do
+ DataBindings.reset!
+ DataBindings.type(:person) do
+ property :name, String
+ property :age, Integer
+ end
+ end
+
+ describe "custom readers" do
+ it "should allow adding a custom converter" do
+ DataBindings.reader(:kolob) { '{"name":"god","age":123}' }
+ a = DataBindings.from_json_kolob.bind(:person)
+ assert_equal "god", a[:name]
+ assert a.valid?
+ end
+ end
+
+ describe "custom writers" do
+ it "should allow adding a custom converter" do
+ data = ''
+ DataBindings.writer(:kolob) { |o| data = o }
+ a = DataBindings.from_json('{"author":"siggy","title":"bible"}').convert_to_json_kolob
+ assert_equal MultiJson.decode('{"author":"siggy","title":"bible"}'), MultiJson.decode(data)
+ end
+
+ it "should allow adding a custom converter for a bound object" do
+ data = ''
+ DataBindings.writer(:kolob) { |o| data = o }
+ a = DataBindings.from_json('{"name":"siggy","age":32}').bind(:person).convert_to_json_kolob
+ assert_equal MultiJson.decode('{"name":"siggy","age":32}'), MultiJson.decode(data)
+ end
+ end
+end
67 test/data_bindings_test.rb
@@ -0,0 +1,67 @@
+require File.expand_path("../test_helper", __FILE__)
+
+describe "Data Bindings" do
+ before do
+ DataBindings.reset!
+ DataBindings.type(:person) do
+ property :name, String
+ property :age, Integer
+ end
+ end
+
+ it "should raise a validation error if neither the key or alias key exist on an object" do
+ DataBindings.type(:file) do
+ property :size, Integer
+ property :filename, String, :alias => [:filenames, :file_name]
+ end
+ a = DataBindings.from_json('{"size":925, "fn":"foo.txt"}').bind(:file)
+ assert_equal({'filename' => "not found"}, a.errors)
+ refute a.errors.empty?
+ refute a.valid?
+ end
+
+ describe "from_*_file" do
+ it "should load from a file" do
+ a = DataBindings.from_json_file(File.expand_path("../fixtures/1.json", __FILE__)).bind_array {
+ all_elements :person
+ }
+ assert a.valid?
+ assert_equal "josh", a[1][:name]
+ end
+ end
+
+ describe "from_*_net" do
+ it "should load from the net" do
+ a = DataBindings.from_json_http("http://localhost/1.json").bind_array {
+ all_elements :person
+ }
+ assert a.valid?
+ assert_equal "josh", a[1][:name]
+ end
+
+ it "should fail to load without auth" do
+ assert_raises DataBindings::HttpError do
+ a = DataBindings.from_json_http("http://secret/1.json").bind_array {
+ all_elements :person
+ }
+ end
+ end
+
+ it "should load with the right auth" do
+ a = DataBindings.from_json_http("http://secret/1.json", :basic_auth => {:username => 'test', :password => 'user'}).bind_array {
+ all_elements :person
+ }
+ assert a.valid?
+ assert_equal "josh", a[1][:name]
+ end
+ end
+
+ describe "strictness via the generator's default" do
+ it "should reject extra properties" do
+ DataBindings.strict = true
+ a = DataBindings.from_json('{"author":"siggy","title":"bible"}').bind { property :author }
+ assert_equal "siggy", a[:author]
+ refute a.valid?
+ end
+ end
+end
1 test/fixtures/1.json
@@ -0,0 +1 @@
+[{"name":"andrew","age":32},{"name":"josh","age":12}]
40 test/json_test.rb
@@ -0,0 +1,40 @@
+require File.expand_path("../test_helper", __FILE__)
+
+describe "Data Bindings json" do
+ before do
+ DataBindings.reset!
+ DataBindings.type(:person) do
+ property :name, String
+ property :age, Integer
+ end
+ end
+
+ it "should create a JSON object" do
+ a = DataBindings.from_json('{"name":"Andrew","age":32}').bind(:person)
+ assert a.errors.empty?
+ assert a.valid?
+ assert_equal "Andrew", a[:name]
+ assert_equal MultiJson.decode("{\"name\":\"Andrew\",\"age\":32}"), MultiJson.decode(a.convert_to_json)
+ end
+
+ it "should refuse to create a JSON object when the data is invalid" do
+ a = DataBindings.from_json('{"name":"Andrew","age":"asd"}').bind(:person)
+ refute a.errors.empty?
+ refute a.valid?
+ assert_raises(DataBindings::FailedValidation) { a.convert_to_json }
+ end
+
+ it "should create a JSON object when the data is invalid and is forced" do
+ a = DataBindings.from_json('{"name":"Andrew","age":"asd"}').bind(:person)
+ refute a.errors.empty?
+ refute a.valid?
+ assert_equal MultiJson.decode("{\"name\":\"Andrew\",\"age\":\"asd\"}"), MultiJson.decode(a.force_convert_to_json)
+ end
+
+ it "should parse JSON" do
+ a = DataBindings.from_json("[1,2,3]").bind([Integer])
+ assert a.valid?
+ assert a.errors.empty?
+ assert_equal [1, 2, 3], [a[0], a[1], a[2]]
+ end
+end
64 test/native_test.rb
@@ -0,0 +1,64 @@
+require File.expand_path("../test_helper", __FILE__)
+
+describe "Data Bindings native" do
+ before do
+ DataBindings.reset!
+ DataBindings.type(:person) do
+ property :name, String
+ property :age, Integer
+ end
+ @person = Class.new { attr_accessor :name, :age; def initialize(name, age); @name, @age = name, age; end }
+ DataBindings.for_native(:person) { |props| @person.new(props[:name], props[:age]) }
+ end
+
+ it "should parse a Native object" do
+ p = @person.new("ben", 23)
+ a = DataBindings.from_native(p)
+ assert_equal "ben", a['name']
+ end
+
+ it "should transform into json without a binding (roughly)" do
+ p = @person.new("ben", 23)
+ assert_raises(DataBindings::UnboundError) { DataBindings.from_native(p).convert_to_json }
+ end
+
+ it "should not transform into json without a binding" do
+ p = @person.new("ben", 23)
+ a = DataBindings.from_native(p).bind(:person).convert_to_json
+ assert_equal({"name" => "ben", "age" => 23}, MultiJson.decode(a))
+ end
+
+ it "should transform into a native object" do
+ a = DataBindings.from_json('{"name":"Andrew","age":32}').bind(:person)
+ assert a.errors.empty?
+ assert a.valid?
+ assert_equal "Andrew", a[:name]
+ assert_equal "Andrew", a.to_native.name
+ end
+
+ it "should create a hash when there is no binding" do
+ a = DataBindings.from_json('{"name":"Andrew","age":32}').to_native
+ assert_equal "Andrew", a.name
+ end
+
+ it "should be able to create nested objets" do
+ DataBindings.type(:address_book) do
+ property :owner, :person
+ property :friends, [:person]
+ end
+ address_book = Class.new {
+ attr_accessor :owner, :friends
+ def initialize(owner, friends)
+ @owner, @friends = owner, friends
+ end
+
+ def find(name)
+ idx = @friends.find_index{|f| f.name == name }
+ idx && @friends[idx]
+ end
+ }
+ DataBindings.for_native(:address_book) { |props| address_book.new(props[:owner], props[:friends]) }
+ book = DataBindings.from_json('{"owner":{"name":"josh","age":34},"friends":[{"name":"grinch","age":123},{"name":"steve","age":23}]}').bind(:address_book).to_native
+ assert_equal 'steve', book.find('steve').name
+ end
+end
60 test/params_test.rb
@@ -0,0 +1,60 @@
+require File.expand_path("../test_helper", __FILE__)
+
+describe "Data Bindings params" do
+
+ describe "params parsing" do
+ it "should parse params" do
+ a = DataBindings.from_params("author=josh&title=great+expectations").bind { property :author; property :title }
+ assert a.valid?
+ assert_equal "josh", a[:author]
+ end
+
+ it "should parse a nested hash" do
+ a = DataBindings.from_params("name[first_name][short]=ben&name[first_name][long]=benjamin&name[last_name]=coe").bind { property :name }
+ assert a.valid?
+ assert_equal "ben", a[:name][:first_name][:short]
+ assert_equal "benjamin", a[:name][:first_name][:long]
+ assert_equal "coe", a[:name][:last_name]
+ end
+
+ it "should parse an array" do
+ a = DataBindings.from_params("name[0][last_name]=coe&name[0][first_name]=ben&name[1][first_name]=josh").bind { property :name }
+ assert a.valid?
+ assert_equal "ben", a[:name][0][:first_name]
+ assert_equal "coe", a[:name][0][:last_name]
+ assert_equal "josh", a[:name][1][:first_name]
+ end
+ end
+
+ describe "params generation" do
+
+ it "should generate params" do
+ a = DataBindings.from_ruby('author' => 'siggy',"title" => 'koran').bind { property :author; property :title }
+ assert a.valid?
+ assert_match "author=siggy", a.convert_to_params
+ end
+
+ it "should generate params for nested hash" do
+ a = DataBindings.from_ruby('author' => {'name' => {'first_name' => 'bill'}}, 'title' => 'koran', 'meta' => {'isbn' => 9999} ).bind do
+ property :author
+ property :title
+ property :meta
+ end
+ assert a.valid?
+ assert_match "author[name][first_name]=bill", a.convert_to_params
+ assert_match "meta[isbn]=9999", a.convert_to_params
+ assert_match "title=koran", a.convert_to_params
+ end
+
+ it "should generate params for nested array" do
+ a = DataBindings.from_ruby('authors' => ['josh', 'ben'], 'title' => 'koran' ).bind do
+ property :authors
+ property :title
+ end
+ assert a.valid?
+ assert_match "authors[0]=josh", a.convert_to_params
+ assert_match "title=koran", a.convert_to_params
+ end
+
+ end
+end
17 test/test_helper.rb
@@ -0,0 +1,17 @@
+require 'minitest/spec'
+require 'minitest/autorun'
+
+$: << File.expand_path("../../lib", __FILE__)
+require 'data_bindings'
+require 'fakeweb'
+
+FakeWeb.allow_net_connect = false
+
+Dir[File.expand_path("../fixtures/*", __FILE__)].each do |f|
+ FakeWeb.register_uri(:get, "http://localhost/#{File.basename(f)}", :body => File.read(f))
+ FakeWeb.register_uri(:get, "http://secret/#{File.basename(f)}", :body => "Unauthorized", :status => ["401", "Unauthorized"])
+ FakeWeb.register_uri(:get, "http://test:user@secret/#{File.basename(f)}", :body => File.read(f))
+end
+
+require 'bson'
+require 'tnetstring'
23 test/tnetstring_test.rb
@@ -0,0 +1,23 @@
+require File.expand_path("../test_helper", __FILE__)
+
+describe "Data Bindings tnetstring" do
+ describe "tnetstring parsing" do
+ it "should parse tnetstring" do
+ a = DataBindings.from_tnetstring(TNetstring.dump(:author => 'siggy', :title => 'bible')).bind { property :author; property :title }
+ assert a.valid?
+ assert_equal "siggy", a[:author]
+ end
+ end
+
+ describe "tnetstring generation" do
+ it "should generate tnetstring" do
+ a = DataBindings.from_ruby('author' => 'siggy',"title" => 'koran').bind { property :author; property :title }
+ assert a.valid?
+ valid_tnetstring_representations = [
+ "33:6:author,5:siggy,5:title,5:koran,}",
+ "33:5:title,5:koran,6:author,5:siggy,}"
+ ]
+ assert valid_tnetstring_representations.include?( a.convert_to_tnetstring )
+ end
+ end
+end
217 test/validation_test.rb
@@ -0,0 +1,217 @@
+describe "Data Bindings validation" do
+ before do
+ DataBindings.reset!
+ end
+
+ it "should revalidate" do
+ a = DataBindings.from_json("[1,2,3]").bind([Integer])
+ assert a.valid?
+ assert a.errors.empty?
+ assert_equal [1, 2, 3], [a[0], a[1], a[2]]
+ a.unshift 'asd'
+ refute a.valid?
+ refute a.errors.empty?
+ end
+
+ it "should allow for a property to have a Boolean type" do
+ DataBindings.type(:person) do
+ property :name, String
+ property :awesome, :boolean
+ end
+ a = DataBindings.from_json('{"name":"Andrew","awesome":"true"}').bind(:person)
+ assert a.errors.empty?
+ assert a.valid?
+ assert_equal true, a[:awesome]
+ end
+
+ it "should allow for a property to have a default value set" do
+ DataBindings.type(:person) do
+ property :name, String
+ property :age, Integer, :default => 25
+ end
+ a = DataBindings.from_json('{"name":"Andrew"}').bind(:person)
+ assert a.errors.empty?
+ assert a.valid?
+ assert_equal 25, a[:age]
+ end
+
+ it "should allow the default value to be overridden with another value." do
+ DataBindings.type(:person) do
+ property :name, String
+ property :age, Integer, :default => 25
+ end
+ a = DataBindings.from_json('{"name":"Andrew", "age":26}').bind(:person)
+ assert a.errors.empty?
+ assert a.valid?
+ assert_equal 26, a[:age]
+ end
+
+ it "should allow for an alias property name to be set for a property" do
+ DataBindings.type(:file) do
+ property :size, Integer
+ property :filename, String, :alias => :file_name
+ end
+ a = DataBindings.from_json('{"size":925, "file_name":"foo.txt"}').bind(:file)
+ assert a.errors.empty?
+ assert a.valid?
+ assert_equal "foo.txt", a[:filename]
+ end
+
+ it "should allow an array of aliases to be provided for a property" do
+ DataBindings.type(:file) do
+ property :size, Integer
+ property :filename, String, :alias => [:filenames, :file_name]
+ end
+ a = DataBindings.from_json('{"size":925, "file_name":"foo.txt"}').bind(:file)
+ assert a.errors.empty?
+ assert a.valid?
+ assert_equal "foo.txt", a[:filename]
+ end
+
+ it "should raise when using bind! with invalid data" do
+ DataBindings.type(:person) do
+ property :name, String
+ property :age, Integer
+ end
+ assert_raises DataBindings::FailedValidation do
+ DataBindings.from_json('{"name":"Andrew","age":"asd"}').bind!(:person)
+ end
+ end
+
+ it "should give back the original object with errors with invalid data" do
+ DataBindings.type(:person) do
+ property :name, String
+ property :age, Integer
+ end
+ a = DataBindings.from_json('{"name":"Andrew","age":"asd"}').bind(:person)
+ assert_equal "Andrew", a[:name]
+ assert_equal "asd", a[:age]
+ refute a.valid?
+ end
+
+ describe "nested DataBindings::FailedValidation" do
+ before do
+ DataBindings.for_native(:person) { |obj| person.new(obj[:name], obj[:age]) }
+ DataBindings.type(:person) do
+ property :name, String
+ property :age, Integer
+ property :books, Array(:book)
+ end
+
+ DataBindings.type(:book) do
+ property :author, String
+ property :title, String
+ end
+ end
+
+ it "should bind to ruby" do
+ a = DataBindings.from_json('{"name":"Andrew","age":32,"books":[{"author":"Siggy","title":"Help Me"},{"author":"Samsum","title":"SC2 and you"}]}').bind(:person)
+ assert a.valid?
+ assert a.errors.empty?
+ assert_equal "Andrew", a[:name]
+ assert_equal "Help Me", a[:books][0][:title]
+ end
+
+ it "should be invalid if a nested object is invalid" do
+ a = DataBindings.from_json('{"name":"Andrew","age":32,"books":[{"author":"Siggy"},{"author":"Samsum","title":"SC2 and you"}]}').bind(:person)
+ refute a.valid?
+ refute a.errors.empty?
+ assert_equal "Andrew", a[:name]
+ assert_equal nil, a[:books][0][:title]
+ end
+ end
+
+ describe "nested validation" do
+ it "should bind to ruby" do
+ a = DataBindings.from_json('{"name":"Andrew","age":32,"book":{"author":"Siggy","title":"Help Me"}}').to_native
+ assert_equal OpenStruct, a.class
+ assert_equal "Siggy", a.book.author
+ end
+ end
+
+ describe "in clause" do
+ before do
+ DataBindings.type(:person) do
+ property :name, String, :in => %w(Steve John Andrew)
+ end
+ end
+
+ it "should have vaild data" do
+ a = DataBindings.from_json('{"name":"Andrew"}').bind(:person)
+ assert_equal "Andrew", a[:name]
+ assert a.valid?
+ end
+
+ it "should handle invaild data" do
+ a = DataBindings.from_json('{"name":"Josh"}').bind(:person)
+ assert_equal "Josh", a[:name]
+ refute a.valid?
+ end
+ end
+
+ describe "inline data type" do
+ before do
+ DataBindings.type(:person) do
+ property :books, [] do
+ property :author
+ property :title
+ end
+ end
+ end
+
+ it "should have vaild data" do
+ a = DataBindings.from_json('{"books":[{"author":"siggy","title":"bible"},{"author":"josh","title":"koran"}]}').bind(:person)
+ assert_equal "josh", a[:books][1][:author]
+ assert a.valid?
+ end
+
+ it "should handle invaild data" do
+ a = DataBindings.from_json('{"books":[{"author":"siggy"},{"author":"josh","title":"koran"}]}').bind(:person)
+ assert_equal "josh", a[:books][1][:author]
+ refute a.valid?
+ end
+
+ it "should allow binding to the data in a non-named way" do
+ a = DataBindings.from_json('{"books":[{"author":"siggy","title":"bible"},{"author":"josh","title":"koran"}]}').bind {
+ property :books, [] do
+ property :author
+ property :title
+ end
+ }
+ assert_equal "josh", a[:books][1][:author]
+ assert a.valid?
+ end
+ end
+
+ describe "ad-hoc data type" do
+ it "should allow binding to the data in a non-named way" do
+ a = DataBindings.from_json('{"books":[{"author":"siggy","title":"bible"},{"author":"josh","title":"koran"}]}').bind {
+ property :books, [] do
+ property :author
+ property :title
+ end
+ }
+ assert_equal "josh", a[:books][1][:author]
+ assert a.valid?
+ end
+
+ it "should handle invaild data" do
+ a = DataBindings.from_json('{"books":[{"author":"siggy"},{"author":"josh","title":"koran"}]}').bind {
+ property :books, [] do
+ property :author
+ property :title
+ end
+ }
+ assert_equal "josh", a[:books][1][:author]
+ refute a.valid?
+ end
+
+ describe "strictness" do
+ it "should reject extra properties" do
+ a = DataBindings.from_json('{"author":"siggy","title":"bible"}').bind(:strict => true) { property :author }
+ assert_equal "siggy", a[:author]
+ refute a.valid?
+ end
+ end
+ end
+end
19 test/xml_test.rb
@@ -0,0 +1,19 @@
+require File.expand_path("../test_helper", __FILE__)
+
+describe "Data Bindings xml" do
+ describe "xml parsing" do
+ it "should parse xml" do
+ a = DataBindings.from_xml("<?xml version=\"1.0\" encoding=\"UTF-8\"?><doc><author>siggy</author><title>koran</title></doc>").bind { property :author; property :title }
+ assert a.valid?
+ assert_equal "siggy", a[:author]
+ end
+ end
+
+ describe "yaml generation" do
+ it "should generate yaml" do
+ a = DataBindings.from_ruby('author' => 'siggy',"title" => 'koran').bind { property :author; property :title }
+ assert a.valid?
+ assert_equal "<?xml version=\"1.0\" encoding=\"UTF-8\"?><doc><author>siggy</author><title>koran</title></doc>", a.convert_to_xml
+ end
+ end
+end
19 test/yaml_test.rb
@@ -0,0 +1,19 @@
+require File.expand_path("../test_helper", __FILE__)
+
+describe "Data Bindings yaml" do
+ describe "yaml parsing" do
+ it "should parse yaml" do
+ a = DataBindings.from_yaml('{author: siggy, title: bible}').bind { property :author; property :title }
+ assert a.valid?
+ assert_equal "siggy", a[:author]
+ end
+ end
+
+ describe "yaml generation" do
+ it "should generate yaml" do
+ a = DataBindings.from_ruby('author' => 'siggy',"title" => 'koran').bind { property :author; property :title }
+ assert a.valid?
+ assert_match /--- ?\nauthor: siggy\ntitle: koran\n/, a.convert_to_yaml
+ end
+ end
+end

0 comments on commit 4e4d47c

Please sign in to comment.