stockboy 0.5.0

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 (112) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.rspec +5 -0
  4. data/.yardopts +7 -0
  5. data/CHANGELOG.md +24 -0
  6. data/Gemfile +12 -0
  7. data/Guardfile +10 -0
  8. data/LICENSE +21 -0
  9. data/README.md +293 -0
  10. data/Rakefile +30 -0
  11. data/lib/stockboy.rb +80 -0
  12. data/lib/stockboy/attribute.rb +11 -0
  13. data/lib/stockboy/attribute_map.rb +74 -0
  14. data/lib/stockboy/candidate_record.rb +130 -0
  15. data/lib/stockboy/configuration.rb +62 -0
  16. data/lib/stockboy/configurator.rb +176 -0
  17. data/lib/stockboy/dsl.rb +68 -0
  18. data/lib/stockboy/exceptions.rb +3 -0
  19. data/lib/stockboy/filter.rb +58 -0
  20. data/lib/stockboy/filter_chain.rb +41 -0
  21. data/lib/stockboy/filters.rb +11 -0
  22. data/lib/stockboy/filters/missing_email.rb +37 -0
  23. data/lib/stockboy/job.rb +241 -0
  24. data/lib/stockboy/mapped_record.rb +59 -0
  25. data/lib/stockboy/provider.rb +238 -0
  26. data/lib/stockboy/providers.rb +11 -0
  27. data/lib/stockboy/providers/file.rb +135 -0
  28. data/lib/stockboy/providers/ftp.rb +205 -0
  29. data/lib/stockboy/providers/http.rb +123 -0
  30. data/lib/stockboy/providers/imap.rb +290 -0
  31. data/lib/stockboy/providers/soap.rb +120 -0
  32. data/lib/stockboy/railtie.rb +28 -0
  33. data/lib/stockboy/reader.rb +59 -0
  34. data/lib/stockboy/readers.rb +11 -0
  35. data/lib/stockboy/readers/csv.rb +115 -0
  36. data/lib/stockboy/readers/fixed_width.rb +121 -0
  37. data/lib/stockboy/readers/spreadsheet.rb +144 -0
  38. data/lib/stockboy/readers/xml.rb +155 -0
  39. data/lib/stockboy/registry.rb +42 -0
  40. data/lib/stockboy/source_record.rb +43 -0
  41. data/lib/stockboy/string_pool.rb +35 -0
  42. data/lib/stockboy/template_file.rb +44 -0
  43. data/lib/stockboy/translations.rb +70 -0
  44. data/lib/stockboy/translations/boolean.rb +58 -0
  45. data/lib/stockboy/translations/date.rb +41 -0
  46. data/lib/stockboy/translations/decimal.rb +33 -0
  47. data/lib/stockboy/translations/default_empty_string.rb +38 -0
  48. data/lib/stockboy/translations/default_false.rb +41 -0
  49. data/lib/stockboy/translations/default_nil.rb +38 -0
  50. data/lib/stockboy/translations/default_true.rb +41 -0
  51. data/lib/stockboy/translations/default_zero.rb +41 -0
  52. data/lib/stockboy/translations/integer.rb +33 -0
  53. data/lib/stockboy/translations/string.rb +33 -0
  54. data/lib/stockboy/translations/time.rb +41 -0
  55. data/lib/stockboy/translations/uk_date.rb +51 -0
  56. data/lib/stockboy/translations/us_date.rb +51 -0
  57. data/lib/stockboy/translator.rb +66 -0
  58. data/lib/stockboy/version.rb +3 -0
  59. data/spec/fixtures/.gitkeep +0 -0
  60. data/spec/fixtures/files/a_garbage.csv +1 -0
  61. data/spec/fixtures/files/test_data-20120101.csv +1 -0
  62. data/spec/fixtures/files/test_data-20120202.csv +1 -0
  63. data/spec/fixtures/files/z_garbage.csv +1 -0
  64. data/spec/fixtures/jobs/test_job.rb +1 -0
  65. data/spec/fixtures/soap/get_list/fault.xml +8 -0
  66. data/spec/fixtures/soap/get_list/success.xml +18 -0
  67. data/spec/fixtures/spreadsheets/test_data.xls +0 -0
  68. data/spec/fixtures/spreadsheets/test_row_options.xls +0 -0
  69. data/spec/fixtures/xml/body.xml +14 -0
  70. data/spec/spec_helper.rb +28 -0
  71. data/spec/stockboy/attribute_map_spec.rb +59 -0
  72. data/spec/stockboy/attribute_spec.rb +11 -0
  73. data/spec/stockboy/candidate_record_spec.rb +150 -0
  74. data/spec/stockboy/configuration_spec.rb +28 -0
  75. data/spec/stockboy/configurator_spec.rb +127 -0
  76. data/spec/stockboy/filter_chain_spec.rb +40 -0
  77. data/spec/stockboy/filter_spec.rb +41 -0
  78. data/spec/stockboy/filters/missing_email_spec.rb +26 -0
  79. data/spec/stockboy/filters_spec.rb +38 -0
  80. data/spec/stockboy/job_spec.rb +238 -0
  81. data/spec/stockboy/mapped_record_spec.rb +30 -0
  82. data/spec/stockboy/provider_spec.rb +34 -0
  83. data/spec/stockboy/providers/file_spec.rb +116 -0
  84. data/spec/stockboy/providers/ftp_spec.rb +143 -0
  85. data/spec/stockboy/providers/http_spec.rb +94 -0
  86. data/spec/stockboy/providers/imap_spec.rb +76 -0
  87. data/spec/stockboy/providers/soap_spec.rb +107 -0
  88. data/spec/stockboy/providers_spec.rb +38 -0
  89. data/spec/stockboy/readers/csv_spec.rb +68 -0
  90. data/spec/stockboy/readers/fixed_width_spec.rb +52 -0
  91. data/spec/stockboy/readers/spreadsheet_spec.rb +121 -0
  92. data/spec/stockboy/readers/xml_spec.rb +94 -0
  93. data/spec/stockboy/readers_spec.rb +30 -0
  94. data/spec/stockboy/source_record_spec.rb +19 -0
  95. data/spec/stockboy/template_file_spec.rb +30 -0
  96. data/spec/stockboy/translations/boolean_spec.rb +48 -0
  97. data/spec/stockboy/translations/date_spec.rb +38 -0
  98. data/spec/stockboy/translations/decimal_spec.rb +23 -0
  99. data/spec/stockboy/translations/default_empty_string_spec.rb +32 -0
  100. data/spec/stockboy/translations/default_false_spec.rb +25 -0
  101. data/spec/stockboy/translations/default_nil_spec.rb +32 -0
  102. data/spec/stockboy/translations/default_true_spec.rb +25 -0
  103. data/spec/stockboy/translations/default_zero_spec.rb +32 -0
  104. data/spec/stockboy/translations/integer_spec.rb +22 -0
  105. data/spec/stockboy/translations/string_spec.rb +22 -0
  106. data/spec/stockboy/translations/time_spec.rb +27 -0
  107. data/spec/stockboy/translations/uk_date_spec.rb +37 -0
  108. data/spec/stockboy/translations/us_date_spec.rb +37 -0
  109. data/spec/stockboy/translations_spec.rb +55 -0
  110. data/spec/stockboy/translator_spec.rb +27 -0
  111. data/stockboy.gemspec +32 -0
  112. metadata +305 -0
@@ -0,0 +1,123 @@
1
+ require 'stockboy/provider'
2
+ require 'httpi'
3
+
4
+ module Stockboy::Providers
5
+
6
+ # Fetches data from an HTTP endpoint
7
+ #
8
+ # == Job template DSL
9
+ #
10
+ # provider :http do
11
+ # get "http://example.com/api/things"
12
+ # end
13
+ #
14
+ class HTTP < Stockboy::Provider
15
+
16
+ # @!group Options
17
+
18
+ # Shorthand for +:method+ and +:uri+ using HTTP GET
19
+ #
20
+ # @!attribute [rw] get
21
+ # @return [String]
22
+ # @example
23
+ # get 'http://example.com/api/things'
24
+ #
25
+ dsl_attr :get, attr_writer: false
26
+
27
+ # Shorthand for +:method+ and +:uri+ using HTTP POST
28
+ #
29
+ # @!attribute [rw] post
30
+ # @return [String]
31
+ # @example
32
+ # post 'http://example.com/api/search'
33
+ #
34
+ dsl_attr :post, attr_writer: false
35
+
36
+ # HTTP method: +:get+ or +:post+
37
+ #
38
+ # @!attribute [rw] method
39
+ # @return [Symbol]
40
+ # @example
41
+ # method :post
42
+ #
43
+ dsl_attr :method, attr_writer: false
44
+
45
+ def uri
46
+ return nil if @uri.nil? || @uri.empty?
47
+ URI(@uri).tap { |u| u.query = URI.encode_www_form(@query) }
48
+ end
49
+
50
+ def uri=(uri)
51
+ @uri = uri
52
+ end
53
+
54
+ # HTTP host and path to the data resource
55
+ #
56
+ # @!attribute [rw] uri
57
+ # @return [String]
58
+ # @example
59
+ # uri 'http://example.com/api/things'
60
+ #
61
+ dsl_attr :uri, attr_accessor: false, alias: :url
62
+
63
+ # Hash of query options
64
+ #
65
+ # @!attribute [rw] query
66
+ # @return [Hash]
67
+ # @example
68
+ # query start: 1, limit: 100
69
+ #
70
+ dsl_attr :query
71
+
72
+ def method=(http_method)
73
+ return @method = nil unless %w(get post).include? http_method.to_s.downcase
74
+ @method = http_method.to_s.downcase.to_sym
75
+ end
76
+
77
+ def get=(uri)
78
+ @method = :get
79
+ @uri = uri
80
+ end
81
+
82
+ def post=(uri)
83
+ @method = :post
84
+ @uri = uri
85
+ end
86
+
87
+ # @!endgroup
88
+
89
+ # Initialize an HTTP provider
90
+ #
91
+ def initialize(opts={}, &block)
92
+ super(opts, &block)
93
+ self.uri = opts[:uri]
94
+ self.method = opts[:method] || :get
95
+ self.query = opts[:query] || Hash.new
96
+ DSL.new(self).instance_eval(&block) if block_given?
97
+ end
98
+
99
+ def client
100
+ return HTTPI unless block_given?
101
+ yield HTTPI
102
+ end
103
+
104
+ private
105
+
106
+ def validate
107
+ errors.add_on_blank [:uri, :method]
108
+ errors.empty?
109
+ end
110
+
111
+ def fetch_data
112
+ request = HTTPI::Request.new
113
+ request.url = uri
114
+ response = HTTPI.send(method, request)
115
+ if response.error?
116
+ errors.add :response, "HTTP respone error: #{response.code}"
117
+ else
118
+ @data = response.body
119
+ end
120
+ end
121
+
122
+ end
123
+ end
@@ -0,0 +1,290 @@
1
+ require 'stockboy/provider'
2
+ require 'net/imap'
3
+ require 'mail'
4
+
5
+ module Stockboy::Providers
6
+
7
+ # Read data from a file attachment in IMAP email
8
+ #
9
+ # == Job template DSL
10
+ #
11
+ # provider :imap do
12
+ # host "imap.example.com"
13
+ # username "arthur@example.com"
14
+ # password "424242"
15
+ # mailbox "INBOX"
16
+ # subject "Daily Report"
17
+ # since Date.today
18
+ # file_name /report-[0-9]+\.csv/
19
+ # end
20
+ #
21
+ class IMAP < Stockboy::Provider
22
+
23
+ # Corresponds to %v mode in +DateTime#strftime+
24
+ VMS_DATE = /\A\d{2}-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2}\z/i
25
+
26
+ # @!group Options
27
+
28
+ # Host name or IP address for IMAP server connection
29
+ #
30
+ # @!attribute [rw] host
31
+ # @return [String]
32
+ # @example
33
+ # host "imap.example.com"
34
+ #
35
+ dsl_attr :host
36
+
37
+ # User name for connection credentials
38
+ #
39
+ # @!attribute [rw] username
40
+ # @return [String]
41
+ # @example
42
+ # username "arthur@example.com"
43
+ #
44
+ dsl_attr :username
45
+
46
+ # Password for connection credentials
47
+ #
48
+ # @!attribute [rw] password
49
+ # @return [String]
50
+ # @example
51
+ # password "424242"
52
+ #
53
+ dsl_attr :password
54
+
55
+ # Where to look for email on the server (usually "INBOX")
56
+ #
57
+ # @!attribute [rw] mailbox
58
+ # @return [String]
59
+ # @example
60
+ # mailbox "INBOX"
61
+ #
62
+ dsl_attr :mailbox
63
+
64
+ # Substring to find contained in matching email subject
65
+ #
66
+ # @!attribute [rw] subject
67
+ # @return [String]
68
+ # @example
69
+ # subject "Daily Report"
70
+ #
71
+ dsl_attr :subject
72
+
73
+ # Email address of the sender
74
+ #
75
+ # @!attribute [rw] from
76
+ # @return [String]
77
+ # @example
78
+ # from "sender+12345@example.com"
79
+ #
80
+ dsl_attr :from
81
+
82
+ # Minimum time sent for matching email
83
+ #
84
+ # @!attribute [rw] since
85
+ # @return [String]
86
+ # @example
87
+ # since Date.today
88
+ #
89
+ dsl_attr :since, alias: :newer_than
90
+
91
+ # Key-value tokens for IMAP search options
92
+ #
93
+ # @!attribute [rw] search
94
+ # @return [String]
95
+ # @example
96
+ # search ['FLAGGED', 'BODY', 'Report attached']
97
+ #
98
+ dsl_attr :search
99
+
100
+ # Name or pattern for matching attachment files. First matching attachment
101
+ # is picked, or the first attachment if not specified.
102
+ #
103
+ # @!attribute [rw] attachment
104
+ # @return [String, Regexp]
105
+ # @example
106
+ # attachment "daily-report.csv"
107
+ # attachment /daily-report-[0-9]+.csv/
108
+ #
109
+ dsl_attr :attachment
110
+
111
+ # Method for choosing which email message to process from potential
112
+ # matches. Default is last by date sent.
113
+ #
114
+ # @!attribute [rw] pick
115
+ # @return [Symbol, Proc]
116
+ # @example
117
+ # pick :last
118
+ # pick :first
119
+ # pick ->(list) {
120
+ # list.max_by { |msgid| client.fetch(msgid, 'SENTON').to_i }
121
+ # }
122
+ #
123
+ dsl_attr :pick
124
+
125
+ # @!endgroup
126
+
127
+ # Library for connection, defaults to +Net::IMAP+
128
+ #
129
+ # @!attribute [rw] imap_client
130
+ #
131
+ def self.imap_client
132
+ @imap_client ||= Net::IMAP
133
+ end
134
+ class << self
135
+ attr_writer :imap_client
136
+ end
137
+
138
+ # Initialize a new IMAP reader
139
+ #
140
+ def initialize(opts={}, &block)
141
+ super(opts, &block)
142
+ @host = opts[:host]
143
+ @username = opts[:username]
144
+ @password = opts[:password]
145
+ @mailbox = opts[:mailbox]
146
+ @subject = opts[:subject]
147
+ @from = opts[:from]
148
+ @since = opts[:since]
149
+ @search = opts[:search]
150
+ @attachment = opts[:attachment]
151
+ @file_smaller = opts[:file_smaller]
152
+ @file_larger = opts[:file_larger]
153
+ @pick = opts[:pick] || :last
154
+ DSL.new(self).instance_eval(&block) if block_given?
155
+ end
156
+
157
+ def client
158
+ raise(ArgumentError, "no block given") unless block_given?
159
+ return yield @open_client if @open_client
160
+
161
+ @open_client = ::Net::IMAP.new(host).tap do |i|
162
+ i.login(username, password)
163
+ i.examine(mailbox)
164
+ end
165
+ yield @open_client
166
+ client.disconnect
167
+ @open_client = nil
168
+ rescue ::Net::IMAP::Error => e
169
+ errors.add :response, "IMAP connection error"
170
+ client.disconnect
171
+ @open_client = nil
172
+ end
173
+
174
+ def delete_data
175
+ raise Stockboy::OutOfSequence, "must confirm #matching_message or calling #data" unless picked_matching_message?
176
+
177
+ logger.info "Deleting message #{username}:#{host} message_uid: #{matching_message}"
178
+ client do |imap|
179
+ imap.uid_store(matching_message, "+FLAGS", [:Deleted])
180
+ imap.expunge
181
+ end
182
+ end
183
+
184
+ def matching_message
185
+ return @matching_message if @matching_message
186
+ keys = fetch_imap_message_keys
187
+ @matching_message = pick_from(keys) unless keys.empty?
188
+ end
189
+
190
+ def clear
191
+ super
192
+ @matching_message = nil
193
+ @data_time = nil
194
+ @data_size = nil
195
+ end
196
+
197
+ private
198
+
199
+ def fetch_data
200
+ client do |imap|
201
+ return false unless matching_message
202
+ mail = ::Mail.new(imap.fetch(matching_message, 'RFC822')[0].attr['RFC822'])
203
+ if part = mail.attachments.detect { |part| validate_attachment(part) }
204
+ validate_file(part.decoded)
205
+ if valid?
206
+ logger.info "Getting file from #{username}:#{host} message_uid #{matching_message}"
207
+ @data = part.decoded
208
+ @data_time = normalize_imap_datetime(mail.date)
209
+ logger.info "Got file from #{username}:#{host} message_uid #{matching_message}"
210
+ end
211
+ end
212
+ end
213
+ !@data.nil?
214
+ end
215
+
216
+ def validate
217
+ errors.add_on_blank [:host, :username, :password]
218
+ errors.empty?
219
+ end
220
+
221
+ def fetch_imap_message_keys
222
+ client { |imap| imap.sort(['DATE'], search_keys, 'UTF-8') }
223
+ end
224
+
225
+ def picked_matching_message?
226
+ !!@matching_message
227
+ end
228
+
229
+ def validate_attachment(part)
230
+ case attachment
231
+ when String
232
+ part.filename == attachment
233
+ when Regexp
234
+ part.filename =~ attachment
235
+ else
236
+ true
237
+ end
238
+ end
239
+
240
+ def search_keys
241
+ keys = []
242
+ keys.concat ['SUBJECT', subject] if subject
243
+ keys.concat ['FROM', from] if from
244
+ keys.concat ['SINCE', date_format(since)] if since
245
+ keys.concat search if search
246
+ keys
247
+ end
248
+
249
+ def date_format(value)
250
+ case value
251
+ when Date, Time, DateTime
252
+ value.strftime('%v')
253
+ when Numeric
254
+ Time.at(value).strftime('%v')
255
+ when String
256
+ return value if value =~ VMS_DATE
257
+ Date.parse(value).strftime('%v')
258
+ end
259
+ end
260
+
261
+ # If activesupport is loaded, it mucks with DateTime#to_time to return
262
+ # self when it has a utc_offset. Handle both to always return a Time.utc.
263
+ #
264
+ def normalize_imap_datetime(datetime)
265
+ datetime.respond_to?(:getutc) ?
266
+ datetime.getutc.to_time : datetime.to_time.utc
267
+ end
268
+
269
+ def validate_file(data_file)
270
+ return errors.add :response, "No matching attachments" unless data_file
271
+ validate_file_smaller(data_file)
272
+ validate_file_larger(data_file)
273
+ end
274
+
275
+ def validate_file_smaller(data_file)
276
+ @data_size ||= data_file.bytesize
277
+ if file_smaller && @data_size > file_smaller
278
+ errors.add :response, "File size larger than #{file_smaller}"
279
+ end
280
+ end
281
+
282
+ def validate_file_larger(data_file)
283
+ @data_size ||= data_file.bytesize
284
+ if file_larger && @data_size < file_larger
285
+ errors.add :response, "File size smaller than #{file_larger}"
286
+ end
287
+ end
288
+ end
289
+
290
+ end
@@ -0,0 +1,120 @@
1
+ require 'stockboy/provider'
2
+ require 'stockboy/string_pool'
3
+ require 'savon'
4
+
5
+ module Stockboy::Providers
6
+
7
+ # Fetch data from a SOAP endpoint
8
+ #
9
+ # Backed by Savon gem, see savon for full configuration options: extra
10
+ # options are passed through.
11
+ #
12
+ class SOAP < Stockboy::Provider
13
+ include Stockboy::StringPool
14
+
15
+ # @!group Options
16
+ #
17
+ # These options correspond to Savon client options
18
+
19
+ # URL with the WSDL document
20
+ #
21
+ # @!attribute [rw] wsdl
22
+ # @return [String]
23
+ # @example
24
+ # wsdl "http://example.com/api/soap?wsdl"
25
+ #
26
+ dsl_attr :wsdl
27
+
28
+ # The name of the request, see your SOAP documentation
29
+ #
30
+ # @!attribute [rw] request
31
+ # @return [String]
32
+ # @example
33
+ # request "allItemsDetails"
34
+ #
35
+ dsl_attr :request
36
+
37
+ # @return [String]
38
+ # @!attribute [rw] namespace
39
+ # Optional if specified in WSDL
40
+ #
41
+ dsl_attr :namespace
42
+
43
+ # @return [String]
44
+ # @!attribute [rw] namespace_id
45
+ # Optional if specified in WSDL
46
+ #
47
+ dsl_attr :namespace_id
48
+
49
+ # @return [String]
50
+ # @!attribute [rw] endpoint
51
+ # Optional if specified in WSDL
52
+ #
53
+ dsl_attr :endpoint
54
+
55
+ # Hash of message options passed in the request, often includes
56
+ # credentials and query options.
57
+ #
58
+ # @!attribute [rw] message
59
+ # @return [Hash]
60
+ # @example
61
+ # message "clientId" => "12345", "updatedSince" => "2012-12-12"
62
+ #
63
+ dsl_attr :message
64
+
65
+ # Hash of optional HTTP request headers
66
+ #
67
+ # @!attribute [rw] headers
68
+ # @return [Hash]
69
+ # @example
70
+ # headers "X-ClientKey" => "12345"
71
+ #
72
+ dsl_attr :headers
73
+
74
+ # @!endgroup
75
+
76
+ # Initialize a new SOAP provider
77
+ #
78
+ def initialize(opts={}, &block)
79
+ super
80
+ DSL.new(self).instance_eval(&block) if block_given?
81
+ end
82
+
83
+ # Connection object to the configured SOAP endpoint
84
+ #
85
+ # @return [Savon::Client]
86
+ #
87
+ def client
88
+ @client ||= Savon.client(client_options)
89
+ return @client unless block_given?
90
+ yield @client
91
+ end
92
+
93
+ private
94
+
95
+ def client_options
96
+ opts = if wsdl
97
+ {wsdl: wsdl}
98
+ elsif endpoint
99
+ {endpoint: endpoint}
100
+ end
101
+ opts[:convert_response_tags_to] = ->(tag) { string_pool(tag) }
102
+ opts[:namespace] = namespace if namespace
103
+ opts[:namespace_identifier] = namespace_id if namespace_id
104
+ opts[:headers] = headers if headers
105
+ opts
106
+ end
107
+
108
+ def validate
109
+ errors.add_on_blank(:endpoint) unless wsdl
110
+ errors.blank?
111
+ end
112
+
113
+ def fetch_data
114
+ with_string_pool do
115
+ @data = client.call(@request, message: message).body
116
+ end
117
+ end
118
+
119
+ end
120
+ end