alula-ruby 0.50.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (102) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +14 -0
  3. data/.env.example +8 -0
  4. data/.github/workflows/gem-push.yml +45 -0
  5. data/.gitignore +23 -0
  6. data/.rspec +3 -0
  7. data/.travis.yml +7 -0
  8. data/Dockerfile +6 -0
  9. data/Gemfile +12 -0
  10. data/Guardfile +42 -0
  11. data/README.md +423 -0
  12. data/Rakefile +6 -0
  13. data/VERSION.md +84 -0
  14. data/alula-docker-compose.yml +80 -0
  15. data/alula.gemspec +38 -0
  16. data/bin/console +15 -0
  17. data/bin/docparse +36 -0
  18. data/bin/genresource +79 -0
  19. data/bin/setup +8 -0
  20. data/bin/testauth +24 -0
  21. data/bin/testprep +9 -0
  22. data/data/docs/Alula_API_Documentation_2021-04-06.html +16240 -0
  23. data/docker-compose.yml +11 -0
  24. data/lib/alula/alula_response.rb +20 -0
  25. data/lib/alula/api_operations/delete.rb +52 -0
  26. data/lib/alula/api_operations/list.rb +45 -0
  27. data/lib/alula/api_operations/request.rb +44 -0
  28. data/lib/alula/api_operations/save.rb +81 -0
  29. data/lib/alula/api_resource.rb +196 -0
  30. data/lib/alula/client.rb +142 -0
  31. data/lib/alula/errors.rb +169 -0
  32. data/lib/alula/filter_builder.rb +271 -0
  33. data/lib/alula/helpers/device_attribute_translations.rb +68 -0
  34. data/lib/alula/list_object.rb +64 -0
  35. data/lib/alula/meta.rb +16 -0
  36. data/lib/alula/monkey_patches.rb +24 -0
  37. data/lib/alula/oauth.rb +118 -0
  38. data/lib/alula/pagination.rb +25 -0
  39. data/lib/alula/procedures/dealer_device_stats_proc.rb +16 -0
  40. data/lib/alula/procedures/dealer_restore_proc.rb +25 -0
  41. data/lib/alula/procedures/dealer_suspend_proc.rb +25 -0
  42. data/lib/alula/procedures/device_assign_proc.rb +23 -0
  43. data/lib/alula/procedures/device_cellular_history_proc.rb +33 -0
  44. data/lib/alula/procedures/device_rateplan_get_proc.rb +21 -0
  45. data/lib/alula/procedures/device_register_proc.rb +31 -0
  46. data/lib/alula/procedures/device_signal_add_proc.rb +42 -0
  47. data/lib/alula/procedures/device_signal_delivered_proc.rb +31 -0
  48. data/lib/alula/procedures/device_signal_update_proc.rb +32 -0
  49. data/lib/alula/procedures/device_unassign_proc.rb +16 -0
  50. data/lib/alula/procedures/device_unregister_proc.rb +21 -0
  51. data/lib/alula/procedures/upload_touchpad_branding_proc.rb +24 -0
  52. data/lib/alula/procedures/user_plansvideo_price_get.rb +21 -0
  53. data/lib/alula/procedures/user_transfer_accept.rb +19 -0
  54. data/lib/alula/procedures/user_transfer_authorize.rb +18 -0
  55. data/lib/alula/procedures/user_transfer_cancel.rb +18 -0
  56. data/lib/alula/procedures/user_transfer_deny.rb +19 -0
  57. data/lib/alula/procedures/user_transfer_reject.rb +19 -0
  58. data/lib/alula/procedures/user_transfer_request.rb +19 -0
  59. data/lib/alula/query_interface.rb +142 -0
  60. data/lib/alula/rate_limit.rb +11 -0
  61. data/lib/alula/relationship_attributes.rb +107 -0
  62. data/lib/alula/resource_attributes.rb +206 -0
  63. data/lib/alula/resources/admin_user.rb +207 -0
  64. data/lib/alula/resources/billing_program.rb +41 -0
  65. data/lib/alula/resources/dealer.rb +218 -0
  66. data/lib/alula/resources/dealer_account_transfer.rb +172 -0
  67. data/lib/alula/resources/dealer_address.rb +89 -0
  68. data/lib/alula/resources/dealer_branding.rb +226 -0
  69. data/lib/alula/resources/dealer_program.rb +75 -0
  70. data/lib/alula/resources/dealer_suspension_log.rb +49 -0
  71. data/lib/alula/resources/device.rb +716 -0
  72. data/lib/alula/resources/device_cellular_status.rb +134 -0
  73. data/lib/alula/resources/device_charge.rb +70 -0
  74. data/lib/alula/resources/device_event_log.rb +167 -0
  75. data/lib/alula/resources/device_program.rb +54 -0
  76. data/lib/alula/resources/event_trigger.rb +75 -0
  77. data/lib/alula/resources/event_webhook.rb +47 -0
  78. data/lib/alula/resources/feature_bysubject.rb +74 -0
  79. data/lib/alula/resources/feature_plan.rb +57 -0
  80. data/lib/alula/resources/feature_planvideo.rb +54 -0
  81. data/lib/alula/resources/feature_price.rb +46 -0
  82. data/lib/alula/resources/receiver_connection.rb +95 -0
  83. data/lib/alula/resources/receiver_group.rb +74 -0
  84. data/lib/alula/resources/revision.rb +91 -0
  85. data/lib/alula/resources/self.rb +61 -0
  86. data/lib/alula/resources/station.rb +130 -0
  87. data/lib/alula/resources/token_exchange.rb +34 -0
  88. data/lib/alula/resources/user.rb +229 -0
  89. data/lib/alula/resources/user_address.rb +121 -0
  90. data/lib/alula/resources/user_phone.rb +116 -0
  91. data/lib/alula/resources/user_preferences.rb +57 -0
  92. data/lib/alula/resources/user_pushtoken.rb +75 -0
  93. data/lib/alula/resources/user_videoprofile.rb +38 -0
  94. data/lib/alula/rest_resource.rb +17 -0
  95. data/lib/alula/rpc_resource.rb +40 -0
  96. data/lib/alula/rpc_response.rb +14 -0
  97. data/lib/alula/singleton_rest_resource.rb +26 -0
  98. data/lib/alula/util.rb +107 -0
  99. data/lib/alula/version.rb +5 -0
  100. data/lib/alula.rb +135 -0
  101. data/lib/parser.rb +199 -0
  102. metadata +282 -0
@@ -0,0 +1,14 @@
1
+ module Alula
2
+ class RpcResponse
3
+ attr_accessor :request_id, :result, :data
4
+
5
+ def initialize(response)
6
+ @request_id = response.data['id']
7
+ @result = response.data['result']
8
+ end
9
+
10
+ def ok?
11
+ @result['success'] == true || (@result['errors'].nil? && @result['error'].nil?)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,26 @@
1
+ module Alula
2
+ class SingletonRestResource < ApiResource
3
+ def self.resource_url
4
+ if self == SingletonRestResource
5
+ raise NotImplementedError, "SingletonRestResource is an abstract class. You should perform actions on its subclasses (Self, etc.)"
6
+ end
7
+ # Namespaces are separated in object names with periods (.) and in URLs
8
+ # with forward slashes (/), so replace the former with the latter.
9
+ "/rest/v1/#{self.get_resource_path}"
10
+ end
11
+
12
+ def resource_url
13
+ self.class.resource_url
14
+ end
15
+
16
+ def self.retrieve
17
+ instance = new
18
+ instance.refresh
19
+ instance
20
+ end
21
+
22
+ def initialize(attributes = {})
23
+ super(nil, attributes)
24
+ end
25
+ end
26
+ end
data/lib/alula/util.rb ADDED
@@ -0,0 +1,107 @@
1
+ module Alula
2
+ class Util
3
+ class << self
4
+ #
5
+ # Some fields use odd casing (abbreviations mostly) that require a strict mapping
6
+ # for camelization to work properly
7
+ CAMELIZE_MAPPING = {
8
+ 'ce_arming_supervision_trouble_zones' => 'CEArmingSupervisionTroubleZones'
9
+ }.freeze
10
+
11
+ # Based on ActiveSupport's underscore method
12
+ # activesupport/lib/active_support/inflector/methods.rb, line 90
13
+ def underscore(camel_cased_word)
14
+ return camel_cased_word unless camel_cased_word =~ /[A-Z-]|::/
15
+ word = camel_cased_word.to_s.gsub(/::/, '/')
16
+ word.gsub!(/([A-Z\d]+)([A-Z][a-z])/,'\1_\2')
17
+ word.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
18
+ word.tr!("-", "_")
19
+ word.downcase!
20
+ word
21
+ end
22
+
23
+ # Check if BVN hex conversion needed
24
+ def convert_hex_crc?(value)
25
+ [32, 33, 34, 35].include? value
26
+ end
27
+
28
+ def camelize(under_scored_word)
29
+ return CAMELIZE_MAPPING[under_scored_word.to_s] if CAMELIZE_MAPPING.key?(under_scored_word.to_s)
30
+ words = under_scored_word.to_s.split(/-|_/)
31
+ words.each_with_index.map{ |el, i| i == 0 ? el.downcase : el.capitalize }.join
32
+ end
33
+
34
+ def upper_camelcase(under_scored_word)
35
+ under_scored_word.to_s.split(/-|_/).map(&:capitalize).join
36
+ end
37
+
38
+ def split_on_word(string, separator_match = /-|_/, &block)
39
+ string.to_s.split(separator_match)
40
+ end
41
+
42
+ # https://stackoverflow.com/questions/9381553/ruby-merge-nested-hash
43
+ def deep_merge(first, second)
44
+ merger = proc do |key, v1, v2|
45
+ if Hash === v1 && Hash === v2
46
+ v1.merge(v2, &merger)
47
+ elsif Array === v1 && Array === v2
48
+ v1 | v2
49
+ elsif [:undefined, nil, :nil].include?(v2)
50
+ v1
51
+ else
52
+ v2
53
+ end
54
+ end
55
+
56
+ first.merge(second.to_h, &merger)
57
+ end
58
+
59
+ def model_errors_from_response(response)
60
+ errors = response.data['errors']
61
+ return false unless errors
62
+
63
+ error_object = errors.each_with_object({}) do |err, collector|
64
+ #
65
+ # This is duplicitive but it ensures that the model
66
+ # itself will have _some_ kind of error detail if we need to print that out.
67
+ collector[:model] ||= []
68
+ collector[:model] << err['detail']
69
+
70
+ # Error objects contain a bunch of info, but all the good stuff
71
+ # is stored in a sub-object named meta
72
+ #
73
+ # meta looks something like this
74
+ # {
75
+ # fieldName: 'field error description',
76
+ # context: {
77
+ # // extra info about the request
78
+ # }
79
+ # }
80
+ # We need the fieldName to join errors to fields, and context gives extra
81
+ # info that's good for debugging.
82
+ err['meta']&.each_pair do |attribute, val|
83
+ if attribute == 'context'
84
+ collector[:context] ||= []
85
+ collector[:context] << val
86
+ else
87
+ underscore_field = Alula::Util.underscore(attribute).to_sym
88
+ collector[underscore_field] = val
89
+ end
90
+ end
91
+ end
92
+
93
+ #
94
+ # De-duplicate context so we don't have the same info (requestId)
95
+ # existing for every field that failed.
96
+ error_object[:context] = error_object[:context].uniq.compact if error_object[:context]
97
+
98
+ #
99
+ # We have UI clients that depend on this being a string.
100
+ # TODO: Update clients and let this be an array?
101
+ error_object[:model] = error_object[:model].uniq.compact.join(', ')
102
+
103
+ error_object
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alula
4
+ VERSION = '0.50.1'
5
+ end
data/lib/alula.rb ADDED
@@ -0,0 +1,135 @@
1
+ require 'alula/version'
2
+ require 'request_store'
3
+ require 'forwardable'
4
+
5
+ require_relative 'alula/resource_attributes'
6
+ require_relative 'alula/relationship_attributes'
7
+ require_relative 'alula/api_operations/list'
8
+ require_relative 'alula/api_operations/request'
9
+ require_relative 'alula/api_operations/save'
10
+ require_relative 'alula/api_operations/delete'
11
+ require_relative 'alula/api_resource'
12
+ require_relative 'alula/rpc_resource'
13
+ require_relative 'alula/rpc_response'
14
+ require_relative 'alula/rest_resource'
15
+ require_relative 'alula/meta'
16
+ require_relative 'alula/pagination'
17
+ require_relative 'alula/singleton_rest_resource'
18
+ require_relative 'alula/alula_response'
19
+ require_relative 'alula/filter_builder'
20
+ require_relative 'alula/query_interface'
21
+ require_relative 'alula/util'
22
+ require_relative 'alula/rate_limit'
23
+
24
+ require_relative 'alula/errors'
25
+ require_relative 'alula/client'
26
+ require_relative 'alula/list_object'
27
+ require_relative 'alula/oauth'
28
+
29
+ require_relative 'alula/helpers/device_attribute_translations'
30
+
31
+ require_relative 'alula/resources/billing_program'
32
+ require_relative 'alula/resources/device'
33
+ require_relative 'alula/resources/device_charge'
34
+ require_relative 'alula/resources/device_event_log'
35
+ require_relative 'alula/resources/device_cellular_status'
36
+ require_relative 'alula/resources/device_program'
37
+ require_relative 'alula/resources/dealer_address'
38
+ require_relative 'alula/resources/dealer_account_transfer'
39
+ require_relative 'alula/resources/dealer_suspension_log'
40
+ require_relative 'alula/resources/dealer_program'
41
+ require_relative 'alula/resources/event_trigger'
42
+ require_relative 'alula/resources/event_webhook'
43
+ require_relative 'alula/resources/self'
44
+ require_relative 'alula/resources/user'
45
+ require_relative 'alula/resources/admin_user'
46
+ require_relative 'alula/resources/user_phone'
47
+ require_relative 'alula/resources/user_address'
48
+ require_relative 'alula/resources/user_pushtoken'
49
+ require_relative 'alula/resources/user_preferences'
50
+ require_relative 'alula/resources/user_videoprofile'
51
+ require_relative 'alula/resources/dealer'
52
+ require_relative 'alula/resources/dealer_branding'
53
+ require_relative 'alula/resources/token_exchange'
54
+ require_relative 'alula/resources/receiver_connection'
55
+ require_relative 'alula/resources/receiver_group'
56
+ require_relative 'alula/resources/revision'
57
+ require_relative 'alula/resources/station'
58
+
59
+ require_relative 'alula/resources/feature_plan'
60
+ require_relative 'alula/resources/feature_planvideo'
61
+ require_relative 'alula/resources/feature_price'
62
+ require_relative 'alula/resources/feature_bysubject'
63
+
64
+ require_relative 'alula/procedures/device_cellular_history_proc'
65
+ require_relative 'alula/procedures/device_rateplan_get_proc'
66
+ require_relative 'alula/procedures/device_register_proc'
67
+ require_relative 'alula/procedures/device_unregister_proc'
68
+ require_relative 'alula/procedures/device_assign_proc'
69
+ require_relative 'alula/procedures/device_unassign_proc'
70
+ require_relative 'alula/procedures/device_signal_add_proc'
71
+ require_relative 'alula/procedures/device_signal_update_proc'
72
+ require_relative 'alula/procedures/device_signal_delivered_proc'
73
+ require_relative 'alula/procedures/dealer_device_stats_proc'
74
+ require_relative 'alula/procedures/dealer_suspend_proc'
75
+ require_relative 'alula/procedures/dealer_restore_proc'
76
+ require_relative 'alula/procedures/upload_touchpad_branding_proc'
77
+ require_relative 'alula/procedures/user_transfer_request'
78
+ require_relative 'alula/procedures/user_transfer_cancel'
79
+ require_relative 'alula/procedures/user_transfer_authorize'
80
+ require_relative 'alula/procedures/user_transfer_deny'
81
+ require_relative 'alula/procedures/user_transfer_reject'
82
+ require_relative 'alula/procedures/user_transfer_accept'
83
+ require_relative 'alula/procedures/user_plansvideo_price_get'
84
+
85
+ module Alula
86
+ #
87
+ # A map of API REST resource names to Alula Ruby classes
88
+ # {
89
+ # 'feature-plansvideo' => Alula::FeaturePlanVideo
90
+ # }
91
+ # This is used to determine which class to initialize when working with
92
+ # API query responses, and included models.
93
+ @@resource_map = [
94
+ Alula::BillingProgram,
95
+ Alula::Device,
96
+ Alula::DeviceCharge,
97
+ Alula::DeviceEventLog,
98
+ Alula::DeviceProgram,
99
+ Alula::DealerAddress,
100
+ Alula::DealerProgram,
101
+ Alula::DeviceCellularStatus,
102
+ Alula::Self,
103
+ Alula::User,
104
+ Alula::AdminUser,
105
+ Alula::UserPhone,
106
+ Alula::UserAddress,
107
+ Alula::UserPushtoken,
108
+ Alula::UserPreferences,
109
+ Alula::UserVideoProfile,
110
+ Alula::Dealer,
111
+ Alula::DealerBranding,
112
+ Alula::ReceiverGroup,
113
+ Alula::Station,
114
+ Alula::DealerAccountTransfer,
115
+ Alula::FeaturePlan,
116
+ Alula::FeaturePlanVideo,
117
+ Alula::FeaturePrice,
118
+ Alula::FeatureBySubject,
119
+ ].each_with_object({}) do |klass, resource_map|
120
+ resource_map[klass.get_type] = klass
121
+ end
122
+
123
+ def self.class_from_resource_type(type)
124
+ @@resource_map[type] || (raise "Unknown resource name #{type}, you need to map this "\
125
+ 'name to a class in the Alula module.')
126
+ end
127
+
128
+ def self.logger
129
+ RequestStore.store['logger'] ||= defined?(Rails) ? Rails.logger : Logger.new(STDOUT)
130
+ end
131
+
132
+ def self.logger=(logger)
133
+ RequestStore.store['logger'] = logger
134
+ end
135
+ end
data/lib/parser.rb ADDED
@@ -0,0 +1,199 @@
1
+ module Parser
2
+ class Files
3
+ NAMES_TO_RESOURCES = {
4
+ /Alula_API_Documentation_([0-9]{4})-([0-9]{2})-([0-9]{2}).html/ => :reqresp,
5
+ /Alula_Connect_Plus_API_Documentation_([0-9]{4})-([0-9]{2})-([0-9]{2}).html/ => :helix
6
+ }.freeze
7
+
8
+ class << self
9
+ def load
10
+ NAMES_TO_RESOURCES.each.each_with_object({}) do |(regex, category), coll|
11
+ coll[category] = find_latest_file(regex)
12
+ end
13
+ end
14
+
15
+ def find_latest_file(pattern)
16
+ Dir.glob('./data/docs/*').grep(pattern).first
17
+ end
18
+ end
19
+ end
20
+
21
+ class Engine
22
+ DELIMITER = 'hr'
23
+ METHOD_TYPE_REGEX = /Method:|Resource:/
24
+ REST_METHOD_TYPE = /Resource:/
25
+ RPC_METHOD_TYPE = /Method:/
26
+
27
+ class << self
28
+ #
29
+ # Documentation is a flat structure. HRs split resources, and all elements
30
+ # for each resource are siblings. This walks through the document and pulls out
31
+ # data into a structured hash suitable for further parsing.
32
+ # There are better ways to do this parsing. This is the quick, brute-force way.
33
+ # Note, this implementation uses parse exceptions to indicate the end of parsing.
34
+ # This will be fragile but works for now.
35
+ def parse_doc(category, file)
36
+ doc = Nokogiri::HTML(File.open(file).read)
37
+
38
+ structured = []
39
+
40
+ datum_structure = {
41
+ type: 'This will be rest or rpc',
42
+ name: 'This will be a resource name',
43
+ path: 'This will be the resource path',
44
+ methods: 'This will be a list of supported methods',
45
+ description: 'This will be a description of the resource',
46
+ relationships: 'This will be the relationships',
47
+ parameters: 'This will be the parameters',
48
+ fields: 'This will be the fields'
49
+ }
50
+
51
+ current_datum = nil
52
+
53
+ # Parse REST docs.
54
+ doc.css('#mainContent').children.each do |node|
55
+ next if node.name != DELIMITER
56
+ structured << current_datum if current_datum
57
+ current_datum = datum_structure.dup
58
+ current_datum = parse_chunk(current_datum, node) rescue nil
59
+ end
60
+
61
+ # Parse RPC docs
62
+ rpc_found = false
63
+ current_datum = nil
64
+ doc.css('#mainContent').children.each do |node|
65
+ # Skip forward until we get to the RPC section
66
+ unless rpc_found
67
+ if node.name == 'h1' and node.text =~ /RPC API/
68
+ rpc_found = true
69
+ else
70
+ next
71
+ end
72
+ end
73
+
74
+ next if node.name != DELIMITER
75
+
76
+ structured << current_datum if current_datum
77
+ current_datum = datum_structure.dup
78
+ current_datum = parse_chunk(current_datum, node) rescue nil
79
+ end
80
+
81
+ structured
82
+ end
83
+
84
+ private
85
+
86
+ def parse_chunk(current_datum, node)
87
+ current_datum[:type] = find_type(node)
88
+ current_datum[:name] = find_name(node).strip
89
+ current_datum[:path] = find_path(node).strip
90
+ current_datum[:description] = find_description(node).strip
91
+ current_datum[:parameters] = find_parameters(node)
92
+
93
+ if current_datum[:type] == :rest
94
+ current_datum[:methods] = find_methods(node)
95
+ current_datum[:relationships] = find_relationships(node)
96
+ current_datum[:fields] = find_fields(node)
97
+ else
98
+ current_datum[:methods] = false
99
+ current_datum[:relationships] = false
100
+ current_datum[:fields] = false
101
+ end
102
+ current_datum
103
+ end
104
+
105
+ def find_type(node)
106
+ sibling = node.next_sibling
107
+ sibling = sibling.next_sibling until sibling.text =~ METHOD_TYPE_REGEX
108
+ if sibling.text =~ RPC_METHOD_TYPE
109
+ return :rpc
110
+ elsif sibling.text =~ REST_METHOD_TYPE
111
+ return :rest
112
+ end
113
+ end
114
+
115
+ def find_name(node)
116
+ sibling = node.next_sibling
117
+ sibling = sibling.next_sibling until sibling.text =~ METHOD_TYPE_REGEX
118
+ sibling.text.gsub(METHOD_TYPE_REGEX, '')
119
+ end
120
+
121
+ def find_path(node)
122
+ sibling = node.next_sibling
123
+ sibling = sibling.next_sibling until sibling.text =~ /Base URL/
124
+ sibling = sibling.next_sibling until sibling.name == 'p'
125
+ sibling.css('code').text
126
+ end
127
+
128
+ def find_methods(node)
129
+ sibling = node.next_sibling
130
+ sibling = sibling.next_sibling until sibling.text =~ /Available methods/
131
+ sibling.css('code').map { |el| el.text }
132
+ end
133
+
134
+ def find_description(node)
135
+ sibling = node.next_sibling
136
+ sibling = sibling.next_sibling until sibling.text =~ /Description/
137
+ sibling = sibling.next_sibling
138
+ text = []
139
+ until sibling.name == 'h3'
140
+ text << sibling.text
141
+ sibling = sibling.next_sibling
142
+ end
143
+ text.join('')
144
+ end
145
+
146
+ def find_relationships(node)
147
+ sibling = node.next_sibling
148
+ sibling = sibling.next_sibling until sibling.text =~ /Relationships/
149
+ case sibling.next_sibling.next_sibling.name
150
+ when 'p'
151
+ return false
152
+ when 'table'
153
+ return table_to_hash(sibling.next_sibling.next_sibling)
154
+ else
155
+ raise 'WTF nothing?'
156
+ end
157
+ end
158
+
159
+ def find_parameters(node)
160
+ sibling = node.next_sibling
161
+ sibling = sibling.next_sibling until sibling.text =~ /Parameters/
162
+ case sibling.next_sibling.next_sibling.name
163
+ when 'p'
164
+ return false
165
+ when 'table'
166
+ return table_to_hash(sibling.next_sibling.next_sibling)
167
+ else
168
+ raise 'WTF nothing?'
169
+ end
170
+ end
171
+
172
+ def find_fields(node)
173
+ sibling = node.next_sibling
174
+ sibling = sibling.next_sibling until sibling.text =~ /Fields/
175
+ case sibling.next_sibling.next_sibling.name
176
+ when 'p'
177
+ return false
178
+ when 'table'
179
+ return table_to_hash(sibling.next_sibling.next_sibling)
180
+ else
181
+ raise 'WTF nothing?'
182
+ end
183
+ end
184
+
185
+ #
186
+ # Transform an HTML table with headers into an array of objects.
187
+ # Object keys are table header text, values are cell value text
188
+ def table_to_hash(node)
189
+ headers = node.css('thead th').map { |el| el.text.downcase }
190
+ node.css('tbody tr').each_with_object([]) do |row, coll|
191
+ vals = row.css('td').map { |c| c.text }
192
+ coll << headers.each_with_index.each_with_object({}) do |(header, index), collector|
193
+ collector[header] = vals[index]
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end