alula-ruby 0.50.1

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 (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