qbxml 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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