qbxml 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. data/.gitignore +17 -0
  2. data/Gemfile +11 -0
  3. data/LICENSE.txt +22 -0
  4. data/README.md +95 -0
  5. data/Rakefile +1 -0
  6. data/TODO.md +3 -0
  7. data/lib/qbxml.rb +12 -0
  8. data/lib/qbxml/hash.rb +148 -0
  9. data/lib/qbxml/qbxml.rb +102 -0
  10. data/lib/qbxml/types.rb +39 -0
  11. data/lib/qbxml/version.rb +3 -0
  12. data/qbxml.gemspec +23 -0
  13. data/schema/qbposxmlops30.xml +8343 -0
  14. data/schema/qbxmlops70.xml +26714 -0
  15. data/spec/backwards_compatibility.rb +26 -0
  16. data/spec/spec_helper.rb +25 -0
  17. data/spec/support/requests/account_query_rq.xml +8 -0
  18. data/spec/support/requests/customer_add_rq.xml +28 -0
  19. data/spec/support/requests/customer_query_iterator_rq.xml +11 -0
  20. data/spec/support/requests/customer_query_rq.xml +9 -0
  21. data/spec/support/requests/estimate_add_rq.xml +45 -0
  22. data/spec/support/requests/invoice_add_rq.xml +40 -0
  23. data/spec/support/requests/item_inventory_add_rq.xml +22 -0
  24. data/spec/support/requests/purchase_order_query_rq.xml +14 -0
  25. data/spec/support/requests/receive_payment_add_rq.xml +35 -0
  26. data/spec/support/requests/receive_payment_query_rq.xml +9 -0
  27. data/spec/support/requests/sales_receipt_add_rq.xml +49 -0
  28. data/spec/support/responses/account_query_rs.xml +42 -0
  29. data/spec/support/responses/customer_add_rs.xml +37 -0
  30. data/spec/support/responses/customer_query_rs.xml +49 -0
  31. data/spec/support/responses/customer_query_terator_rs.xml +34 -0
  32. data/spec/support/responses/item_inventory_add_rs.xml +40 -0
  33. data/spec/support/responses/purchase_order_query_rs.xml +84 -0
  34. data/spec/support/responses/receive_payment_query_rs.xml +51 -0
  35. data/spec/support/responses/sales_receipt_add_rs.xml +89 -0
  36. metadata +150 -0
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
3
+
4
+ # test
5
+ gem 'pry'
6
+ gem 'pry-nav'
7
+ gem 'rspec'
8
+ gem 'simplecov', require: false, group: :test
9
+
10
+ # backwards compatibility tests
11
+ gem 'quickbooks_api', path: '../old/quickbooks_api'
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2012 Alex Skryl
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # Qbxml
2
+
3
+ Qbxml is a QBXML parser and validation tool.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'qbxml'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install qbxml
18
+
19
+ ## Usage
20
+
21
+ ### Initialization
22
+
23
+ The parser can be initialized to either Quickbooks (:qb) or Quickbooks Point of
24
+ Sale (:qbpos)
25
+
26
+ ```ruby
27
+ q = Qbxml.new(:qb)
28
+ ```
29
+
30
+ ### API Introspection
31
+
32
+ Return all types defined in the schema
33
+
34
+ ```ruby
35
+ q.types
36
+ ```
37
+
38
+ Return all types matching a certain pattern
39
+
40
+ ```ruby
41
+ q.types('Customer')
42
+
43
+ q.types(/Customer/)
44
+ ```
45
+
46
+ Print the xml template for a specific type
47
+
48
+ ```ruby
49
+ puts q.describe('CustomerModRq')
50
+ ```
51
+
52
+ ### QBXML To Ruby
53
+
54
+ Convert valid QBXML to a ruby hash
55
+
56
+ ```ruby
57
+ q.from_qbxml(xml)
58
+ ```
59
+
60
+ ### Ruby To QBXML
61
+
62
+ Convert a ruby hash to QBXML, skipping validation
63
+
64
+ ```ruby
65
+ q.to_qbxml(hsh)
66
+ ```
67
+
68
+ Convert a ruby hash to QBXML and validate all types
69
+
70
+ ```ruby
71
+ q.to_qbxml(hsh, validate: true)
72
+ ```
73
+
74
+ ## Caveats
75
+
76
+ Correct case conversion depends on the following ActiveSupport inflection
77
+ settings. Correct behaviour cannot be guaranteed if any of the following
78
+ inflections are modified.
79
+
80
+ ```ruby
81
+ ACRONYMS = ['AP', 'AR', 'COGS', 'COM', 'UOM', 'QBXML', 'UI', 'AVS', 'ID',
82
+ 'PIN', 'SSN', 'COM', 'CLSID', 'FOB', 'EIN', 'UOM', 'PO', 'PIN', 'QB']
83
+
84
+ ActiveSupport::Inflector.inflections do |inflect|
85
+ ACRONYMS.each { |a| inflect.acronym a }
86
+ end
87
+ ```
88
+
89
+ ## Contributing
90
+
91
+ 1. Fork it
92
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
93
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
94
+ 4. Push to the branch (`git push origin my-new-feature`)
95
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/TODO.md ADDED
@@ -0,0 +1,3 @@
1
+ stupid backwards compatibility
2
+ - rename :xml_attributes to __attributes__
3
+ - remove empty xml_attributes hashes
data/lib/qbxml.rb ADDED
@@ -0,0 +1,12 @@
1
+ require "qbxml/version"
2
+
3
+ require 'nokogiri'
4
+ require 'active_support/builder'
5
+ require 'active_support/inflections'
6
+ require 'active_support/core_ext/string'
7
+
8
+ class Qbxml; end
9
+
10
+ require_relative 'qbxml/types.rb'
11
+ require_relative 'qbxml/qbxml.rb'
12
+ require_relative 'qbxml/hash.rb'
data/lib/qbxml/hash.rb ADDED
@@ -0,0 +1,148 @@
1
+ # XML Conversion References
2
+ #
3
+ # https://github.com/rails/rails/blob/master/activesupport/lib/active_support/core_ext/hash/conversions.rb
4
+ # https://github.com/rails/rails/blob/master/activesupport/lib/active_support/xml_mini/nokogiri.rb
5
+ #
6
+ #
7
+ class Qbxml::Hash < ::Hash
8
+ include Qbxml::Types
9
+
10
+ CONTENT_ROOT = '__content__'.freeze
11
+ ATTR_ROOT = 'xml_attributes'.freeze
12
+ IGNORED_KEYS = [ATTR_ROOT]
13
+
14
+
15
+ def self.from_hash(hash, opts = {}, &block)
16
+ key_proc = \
17
+ if opts[:camelize]
18
+ lambda { |k| k.camelize }
19
+ elsif opts[:underscore]
20
+ lambda { |k| k.underscore }
21
+ end
22
+
23
+ deep_convert(hash, opts, &key_proc)
24
+ end
25
+
26
+ def to_xml(opts = {})
27
+ hash = self.class.to_xml(self, opts)
28
+ end
29
+
30
+ def self.to_xml(hash, opts = {})
31
+ opts[:root], hash = hash.first
32
+ opts[:attributes] = hash.delete(ATTR_ROOT)
33
+ hash_to_xml(hash, opts)
34
+ end
35
+
36
+ def self.from_xml(xml, opts = {})
37
+ from_hash(
38
+ xml_to_hash(Nokogiri::XML(xml).root, {}, opts), opts)
39
+ end
40
+
41
+ private
42
+
43
+ def self.hash_to_xml(hash, opts = {})
44
+ opts = opts.dup
45
+ opts[:indent] ||= 2
46
+ opts[:root] ||= :hash
47
+ opts[:attributes] ||= (hash.delete(ATTR_ROOT) || {})
48
+ opts[:xml_directive] ||= [:xml, {}]
49
+ opts[:builder] ||= Builder::XmlMarkup.new(indent: opts[:indent])
50
+ opts[:skip_types] = true unless opts.key?(:skip_types)
51
+ opts[:skip_instruct] = false unless opts.key?(:skip_instruct)
52
+ builder = opts[:builder]
53
+
54
+ unless opts.delete(:skip_instruct)
55
+ builder.instruct!(opts[:xml_directive].first, opts[:xml_directive].last)
56
+ end
57
+
58
+ builder.tag!(opts[:root], opts.delete(:attributes)) do
59
+ hash.each do |key, val|
60
+ case val
61
+ when Hash
62
+ self.hash_to_xml(val, opts.merge({root: key, skip_instruct: true}))
63
+ when Array
64
+ val.map { |i| self.hash_to_xml(i, opts.merge({root: key, skip_instruct: true})) }
65
+ else
66
+ builder.tag!(key, val, {})
67
+ end
68
+ end
69
+
70
+ yield builder if block_given?
71
+ end
72
+ end
73
+
74
+ def self.xml_to_hash(node, hash = {}, opts = {})
75
+ node_hash = {CONTENT_ROOT => '', ATTR_ROOT => {}}
76
+ name = node.name
77
+ schema = opts[:schema]
78
+
79
+ # Insert node hash into parent hash correctly.
80
+ case hash[name]
81
+ when Array then hash[name] << node_hash
82
+ when Hash then hash[name] = [hash[name], node_hash]
83
+ else hash[name] = node_hash
84
+ end
85
+
86
+ # Handle child elements
87
+ node.children.each do |c|
88
+ if c.element?
89
+ xml_to_hash(c, node_hash, opts)
90
+ elsif c.text? || c.cdata?
91
+ node_hash[CONTENT_ROOT] << c.content
92
+ end
93
+ end
94
+
95
+ # Handle attributes
96
+ node.attribute_nodes.each { |a| node_hash[ATTR_ROOT][a.node_name] = a.value }
97
+
98
+ # TODO: Strip text
99
+ # node_hash[CONTENT_ROOT].strip!
100
+
101
+ # Format node
102
+ if node_hash.size > 2 || node_hash[ATTR_ROOT].present?
103
+ node_hash.delete(CONTENT_ROOT)
104
+ elsif node_hash[CONTENT_ROOT].present?
105
+ node_hash.delete(ATTR_ROOT)
106
+ hash[name] = \
107
+ if schema
108
+ typecast(schema, node.path, node_hash[CONTENT_ROOT])
109
+ else
110
+ node_hash[CONTENT_ROOT]
111
+ end
112
+ else
113
+ hash[name] = node_hash[CONTENT_ROOT]
114
+ end
115
+
116
+ hash
117
+ end
118
+
119
+
120
+ private
121
+
122
+ def self.typecast(schema, xpath, value)
123
+ type_path = xpath.gsub(/\[\d+\]/,'')
124
+ type_proc = Qbxml::TYPE_MAP[schema.xpath(type_path).first.try(:text)]
125
+ raise "#{xpath} is not a valid type" unless type_proc
126
+ type_proc[value]
127
+ end
128
+
129
+ def self.deep_convert(hash, opts = {}, &block)
130
+ hash.inject(self.new) do |h, (k,v)|
131
+ ignored = IGNORED_KEYS.include?(k)
132
+ if ignored
133
+ h[k] = v
134
+ else
135
+ key = block_given? ? yield(k.to_s) : k
136
+ h[key] = \
137
+ case v
138
+ when Hash
139
+ deep_convert(v, &block)
140
+ when Array
141
+ v.map { |i| i.is_a?(Hash) ? deep_convert(i, &block) : i }
142
+ else v
143
+ end
144
+ end; h
145
+ end
146
+ end
147
+
148
+ end
@@ -0,0 +1,102 @@
1
+ class Qbxml
2
+ include Types
3
+
4
+ SCHEMA_PATH = File.expand_path('../../../schema', __FILE__)
5
+
6
+ SCHEMAS = {
7
+ qb: "#{SCHEMA_PATH}/qbxmlops70.xml",
8
+ qbpos: "#{SCHEMA_PATH}/qbposxmlops30.xml"
9
+ }.freeze
10
+
11
+ HIDE_IVARS = [:@doc].freeze
12
+
13
+ def initialize(key = :qb)
14
+ @schema = key
15
+ @doc = parse_schema(key)
16
+ end
17
+
18
+ # returns all xml nodes matching a specified pattern
19
+ #
20
+ def types(pattern = nil)
21
+ @types ||= @doc.xpath("//*").map { |e| e.name }.uniq
22
+
23
+ pattern ?
24
+ @types.select { |t| t =~ Regexp.new(pattern) } :
25
+ @types
26
+ end
27
+
28
+ # returns the xml node for the specified type
29
+ #
30
+ def describe(type)
31
+ @doc.xpath("//#{type}").first
32
+ end
33
+
34
+ # converts a hash to qbxml with optional validation
35
+ #
36
+ def to_qbxml(hash, opts = {})
37
+ hash = Qbxml::Hash.from_hash(hash, camelize: true)
38
+ hash = namespace_qbxml_hash(hash) unless opts[:no_namespace]
39
+ validate_qbxml_hash(hash) if opts[:validate]
40
+
41
+ Qbxml::Hash.to_xml(hash, xml_directive: XML_DIRECTIVES[@schema])
42
+ end
43
+
44
+ # converts qbxml to a hash
45
+ #
46
+ def from_qbxml(xml, opts = {})
47
+ hash = Qbxml::Hash.from_xml(xml, underscore: true, schema: @doc)
48
+
49
+ opts[:no_namespace] ? hash : namespace_qbxml_hash(hash)
50
+ end
51
+
52
+ # making this more sane so that it doesn't dump the whole schema doc to stdout
53
+ # every time
54
+ #
55
+ def inspect
56
+ prefix = "#<#{self.class}:0x#{self.__id__.to_s(16)} "
57
+
58
+ (instance_variables - HIDE_IVARS).each do |var|
59
+ prefix << "#{var}=#{instance_variable_get(var).inspect}"
60
+ end
61
+
62
+ return "#{prefix}>"
63
+ end
64
+
65
+ # private
66
+
67
+ def parse_schema(key)
68
+ File.open(select_schema(key)) { |f| Nokogiri::XML(f) }
69
+ end
70
+
71
+ def select_schema(schema_key)
72
+ SCHEMAS[schema_key] || raise("invalid schema, must be one of #{SCHEMA.keys.inspect}")
73
+ end
74
+
75
+ # hash to qbxml
76
+
77
+ def namespace_qbxml_hash(hash)
78
+ node = describe(hash.keys.first)
79
+ return hash unless node
80
+
81
+ path = node.path.split('/')[1...-1].reverse
82
+ path.inject(hash) { |h,p| Qbxml::Hash[ p => h ] }
83
+ end
84
+
85
+ def validate_qbxml_hash(hash, path = [])
86
+ hash.each do |k,v|
87
+ next if k == Qbxml::HASH::ATTR_ROOT
88
+ key_path = path.dup << k
89
+ if v.is_a?(Hash)
90
+ validate_qbxml_hash(v, key_path)
91
+ else
92
+ validate_xpath(key_path)
93
+ end
94
+ end
95
+ end
96
+
97
+ def validate_xpath(path)
98
+ xpath = "/#{path.join('/')}"
99
+ raise "#{xpath} is not a valid type" if @doc.xpath(xpath).empty?
100
+ end
101
+
102
+ end
@@ -0,0 +1,39 @@
1
+ module Qbxml::Types
2
+
3
+ XML_DIRECTIVES = {
4
+ :qb => [:qbxml, { version: '7.0' }],
5
+ :qbpos => [:qbposxml, { version: '3.0' }]
6
+ }.freeze
7
+
8
+ FLOAT_CAST = Proc.new {|d| d ? Float(d) : 0.0}
9
+ BOOL_CAST = Proc.new {|d| d ? (d == 'True' ? true : false) : false }
10
+ DATE_CAST = Proc.new {|d| d ? Date.parse(d).strftime("%Y-%m-%d") : Date.today.strftime("%Y-%m-%d") }
11
+ TIME_CAST = Proc.new {|d| d ? Time.parse(d).xmlschema : Time.now.xmlschema }
12
+ INT_CAST = Proc.new {|d| d ? Integer(d.to_i) : 0 }
13
+ STR_CAST = Proc.new {|d| d ? String(d) : ''}
14
+
15
+ TYPE_MAP= {
16
+ "AMTTYPE" => FLOAT_CAST,
17
+ "BOOLTYPE" => BOOL_CAST,
18
+ "DATETIMETYPE" => TIME_CAST,
19
+ "DATETYPE" => DATE_CAST,
20
+ "ENUMTYPE" => STR_CAST,
21
+ "FLOATTYPE" => FLOAT_CAST,
22
+ "GUIDTYPE" => STR_CAST,
23
+ "IDTYPE" => STR_CAST,
24
+ "INTTYPE" => INT_CAST,
25
+ "PERCENTTYPE" => FLOAT_CAST,
26
+ "PRICETYPE" => FLOAT_CAST,
27
+ "QUANTYPE" => INT_CAST,
28
+ "STRTYPE" => STR_CAST,
29
+ "TIMEINTERVALTYPE" => STR_CAST
30
+ }
31
+
32
+ ACRONYMS = ['AP', 'AR', 'COGS', 'COM', 'UOM', 'QBXML', 'UI', 'AVS', 'ID',
33
+ 'PIN', 'SSN', 'COM', 'CLSID', 'FOB', 'EIN', 'UOM', 'PO', 'PIN', 'QB']
34
+
35
+ ActiveSupport::Inflector.inflections do |inflect|
36
+ ACRONYMS.each { |a| inflect.acronym a }
37
+ end
38
+
39
+ end