stockboy 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
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