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,59 @@
1
+ module Stockboy
2
+
3
+ # This represents the "output" side of a {CandidateRecord}
4
+ #
5
+ # Based on the current attribute map, it will have reader methods for the
6
+ # output values of each attribute. This is similar to an OpenStruct, but
7
+ # more efficient since we cache the defined methods.
8
+ #
9
+ # @example
10
+ # output = MappedRecord.new(first_name: "Zaphod")
11
+ # output.first_name # => "Zaphod"
12
+ #
13
+ class MappedRecord
14
+
15
+ # This is an optimization to avoid relying on method_missing.
16
+ #
17
+ # This module holds a pool of already-defined accessor methods for sets of
18
+ # record attributes. Each set of methods is held in a module that gets
19
+ # extended into new MappedRecords.
20
+ #
21
+ # @visibility private
22
+ #
23
+ module AccessorMethods
24
+ def self.for(attrs)
25
+ @module_registry ||= Hash.new
26
+ @module_registry[attrs] ||= build_module(attrs)
27
+ end
28
+
29
+ def self.build_module(attr_accessor_keys)
30
+ Module.new do
31
+ attr_accessor_keys.each do |key|
32
+ define_method key do
33
+ @fields[key]
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ # Initialize a new MappedRecord
41
+ #
42
+ # @param [Hash<Symbol>] fields
43
+ # Keys map to reader methods
44
+ #
45
+ def initialize(fields)
46
+ mod = AccessorMethods.for(fields.keys)
47
+ extend mod
48
+ @fields = fields
49
+ freeze
50
+ end
51
+
52
+ # @return [String]
53
+ #
54
+ def to_s
55
+ @fields.to_s
56
+ end
57
+
58
+ end
59
+ end
@@ -0,0 +1,238 @@
1
+ require 'logger'
2
+ require 'active_model/errors'
3
+ require 'active_model/naming'
4
+ require 'stockboy/dsl'
5
+ require 'stockboy/exceptions'
6
+
7
+ module Stockboy
8
+
9
+ # Provider objects handle the connection and capture of data from remote
10
+ # sources. This is an abstract superclass to help implement different
11
+ # providers.
12
+ #
13
+ # == Interface
14
+ #
15
+ # A provider object must implement the following (private) methods:
16
+ #
17
+ # [validate]
18
+ # Verify the parameters required for the data source are set to
19
+ # ensure a connection can be established.
20
+ # [fetch_data]
21
+ # Populate the object's +@data+ with raw content from source. This will
22
+ # usually be a raw string, and should not be parsed at this stage.
23
+ # Depending on the implementation, this may involve any of:
24
+ # * Establishing a connection
25
+ # * Navigating to a directory
26
+ # * Listing available files matching the configuration
27
+ # * Picking the appropriate file
28
+ # * And finally, reading/downloading data
29
+ # This should also capture the timestamp of the data resource into
30
+ # +@data_time+. This should be the actual created or updated time of the
31
+ # data file from the source.
32
+ #
33
+ # @abstract
34
+ #
35
+ class Provider
36
+ extend Stockboy::DSL
37
+ extend ActiveModel::Naming # Required by ActiveModel::Errors
38
+
39
+ # Default logger if none is provided to the instance
40
+ #
41
+ # @return [Logger]
42
+ #
43
+ def self.logger
44
+ Logger.new(STDERR)
45
+ end
46
+
47
+ # @return [Logger]
48
+ #
49
+ attr_accessor :logger
50
+
51
+ # @return [ActiveModel::Errors]
52
+ #
53
+ attr_reader :errors
54
+
55
+ # Timestamp of the received data
56
+ #
57
+ # @return [Time]
58
+ #
59
+ attr_reader :data_time
60
+
61
+ # @return [String]
62
+ #
63
+ def inspect
64
+ "#<#{self.class}:#{self.object_id} data_size=#{@data_size or 'nil'} errors=#{@errors.full_messages}>"
65
+ end
66
+
67
+ # Must be called by subclasses via +super+ to set up dependencies
68
+ #
69
+ # @param [Hash] opts
70
+ # @yield DSL context for configuration
71
+ #
72
+ def initialize(opts={}, &block)
73
+ @logger = opts.delete(:logger) || Stockboy.configuration.logger
74
+ clear
75
+ end
76
+
77
+ # Raw input data from the source
78
+ #
79
+ # @!attribute [r] data
80
+ #
81
+ def data
82
+ return @data if @data
83
+ fetch_data if validate_config?
84
+ @data
85
+ end
86
+
87
+ # Reset received data
88
+ #
89
+ # @return [Boolean] Always true
90
+ #
91
+ def clear
92
+ @data = nil
93
+ @data_time = nil
94
+ @data_size = nil
95
+ @errors = ActiveModel::Errors.new(self)
96
+ true
97
+ end
98
+ alias_method :reset, :clear
99
+
100
+ # Reload provided data
101
+ #
102
+ # @return [String] Raw data
103
+ #
104
+ def reload
105
+ clear
106
+ fetch_data if validate_config?
107
+ @data
108
+ end
109
+
110
+ # Does the provider have what it needs for fetching data?
111
+ #
112
+ # @return [Boolean]
113
+ #
114
+ def valid?
115
+ validate
116
+ end
117
+
118
+ private
119
+
120
+ # Subclass should assign +@data+ with raw input, usually a string
121
+ #
122
+ # @abstract
123
+ #
124
+ def fetch_data
125
+ raise NoMethodError, "#{self.class}#fetch_data needs implementation"
126
+ end
127
+
128
+ # Use errors.add(:attribute, "Message") provided by ActiveModel
129
+ # for validating required provider parameters before attempting
130
+ # to make connections and retrieve data.
131
+ #
132
+ # @abstract
133
+ #
134
+ def validate
135
+ raise NoMethodError, "#{self.class}#validate needs implementation"
136
+ end
137
+
138
+ def validate_config?
139
+ unless validation = valid?
140
+ logger.error do
141
+ "Invalid #{self.class} provider configuration: #{errors.full_messages}"
142
+ end
143
+ end
144
+ validation
145
+ end
146
+
147
+ # Required by ActiveModel::Errors
148
+ def read_attribute_for_validation(attr)
149
+ send(attr)
150
+ end
151
+
152
+ # Required by ActiveModel::Errors
153
+ def self.human_attribute_name(attr, options = {})
154
+ attr
155
+ end
156
+
157
+ # Required by ActiveModel::Errors
158
+ def self.lookup_ancestors
159
+ [self]
160
+ end
161
+
162
+ # When picking files from a list you can supply +:first+ or +:last+ to the
163
+ # provider's +pick+ option, or else a block that can reduce to a single
164
+ # value, like:
165
+ #
166
+ # proc do |best_match, current_match|
167
+ # current_match.better_than?(best_match) ?
168
+ # current_match : best_match
169
+ # end
170
+ #
171
+ def pick_from(list, &block)
172
+ case @pick
173
+ when Symbol
174
+ list.public_send @pick
175
+ when Proc
176
+ list.reduce &@pick
177
+ end
178
+ end
179
+
180
+ end
181
+
182
+ # @!macro [new] provider.pick_validation
183
+ # This validation option is applied after a matching file is picked.
184
+
185
+ # @!macro [new] provider.pick_option
186
+ # @group Options
187
+ #
188
+ # @!attribute [rw] pick
189
+ # Method for choosing which file to process from potential matches.
190
+ # @example
191
+ # pick :last
192
+ # pick :first
193
+ # pick ->(list) {
194
+ # list.max_by { |name| Time.strptime(name[/\d+/], "%m%d%Y").to_i }
195
+ # }
196
+
197
+ # @!macro [new] provider.file_options
198
+ # @group Options
199
+ #
200
+ # @!attribute [rw] file_name
201
+ # A string (glob) or regular expression matching files. E.g. one of:
202
+ # @return [String, Regexp]
203
+ # @example
204
+ # file_name "export-latest.csv"
205
+ # file_name "export-*.csv"
206
+ # file_name /export-\d{4}-\d{2}-\d{2}.csv/
207
+ #
208
+ # @!attribute [rw] file_dir
209
+ # Path where data files can be found. This should be an absolute path.
210
+ # @return [String]
211
+ # @example
212
+ # file_dir "/data"
213
+ #
214
+ # @!attribute [rw] file_newer
215
+ # Validates that the file to be processed is recent enough. To guard
216
+ # against processing an old file (even if it's the latest one), this should
217
+ # be set to the frequency you expect to receive new files for periodic
218
+ # processing.
219
+ # @macro provider.pick_validation
220
+ # @return [Time, Date]
221
+ # @example
222
+ # since Date.today
223
+ #
224
+ # @!attribute [rw] file_smaller
225
+ # Validates the maximum data size for the matched file, in bytes
226
+ # @return [Fixnum]
227
+ # @macro provider.pick_validation
228
+ # @example
229
+ # file_smaller 1024^3
230
+ #
231
+ # @!attribute [rw] file_larger
232
+ # Validates the minimum file size for the matched file, in bytes. This can # help guard against processing zero-byte or truncated files.
233
+ # @return [Fixnum]
234
+ # @macro provider.pick_validation
235
+ # @example
236
+ # file_larger 1024
237
+
238
+ end
@@ -0,0 +1,11 @@
1
+ require 'stockboy/registry'
2
+
3
+ module Stockboy
4
+
5
+ # Registry of available providers
6
+ #
7
+ module Providers
8
+ extend Stockboy::Registry
9
+ end
10
+
11
+ end
@@ -0,0 +1,135 @@
1
+ require 'stockboy/provider'
2
+
3
+ module Stockboy::Providers
4
+
5
+ # Get data from a local file
6
+ #
7
+ # Allows for selecting the appropriate file to be read from the given
8
+ # directory by glob pattern or regex pattern. By default the +:last+ file in
9
+ # the list is used, but can be controlled by sorting and reducing with the
10
+ # {#pick} option.
11
+ #
12
+ # == Job template DSL
13
+ #
14
+ # provider :file do
15
+ # file_dir '/data'
16
+ # file_name /report-[0-9]+\.csv/
17
+ # pick ->(list) { list[-2] }
18
+ # end
19
+ #
20
+ class File < Stockboy::Provider
21
+
22
+ # @!group Options
23
+
24
+ # @macro provider.file_options
25
+ dsl_attr :file_name
26
+ dsl_attr :file_dir
27
+ dsl_attr :file_newer, alias: :since
28
+ dsl_attr :file_smaller
29
+ dsl_attr :file_larger
30
+
31
+ # @macro provider.pick_option
32
+ dsl_attr :pick
33
+
34
+ # @!endgroup
35
+
36
+ # Initialize a File provider
37
+ #
38
+ def initialize(opts={}, &block)
39
+ super(opts, &block)
40
+ @file_dir = opts[:file_dir]
41
+ @file_name = opts[:file_name]
42
+ @file_newer = opts[:file_newer]
43
+ @file_smaller = opts[:file_smaller]
44
+ @file_larger = opts[:file_larger]
45
+ @pick = opts[:pick] || :last
46
+ DSL.new(self).instance_eval(&block) if block_given?
47
+ end
48
+
49
+ def delete_data
50
+ raise Stockboy::OutOfSequence, "must confirm #matching_file or calling #data" unless picked_matching_file?
51
+
52
+ logger.info "Deleting file #{file_dir}/#{matching_file}"
53
+ ::File.delete matching_file
54
+ end
55
+
56
+ def matching_file
57
+ return @matching_file if @matching_file
58
+ files = case file_name
59
+ when Regexp
60
+ Dir.entries(file_dir)
61
+ .select { |i| i =~ file_name }
62
+ .map { |i| ::File.join(file_dir, i) }
63
+ when String
64
+ Dir[::File.join(file_dir, file_name)]
65
+ end
66
+ @matching_file = pick_file_from(files) if files.any?
67
+ end
68
+
69
+ def clear
70
+ super
71
+ @matching_file = nil
72
+ @data_size = nil
73
+ @data_time = nil
74
+ end
75
+
76
+ private
77
+
78
+ def fetch_data
79
+ errors.add(:base, "File #{file_name} not found") unless matching_file
80
+ data_file = ::File.new(matching_file, 'r') if matching_file
81
+ validate_file(data_file)
82
+ if valid?
83
+ logger.info "Getting file #{file_dir}/#{matching_file}"
84
+ @data = data_file.read
85
+ logger.info "Got file #{file_dir}/#{matching_file} (#{@data_size} bytes)"
86
+ end
87
+ end
88
+
89
+ def validate
90
+ errors.add_on_blank [:file_dir, :file_name]
91
+ errors.empty?
92
+ end
93
+
94
+ def pick_file_from(list)
95
+ case @pick
96
+ when Symbol
97
+ list.public_send @pick
98
+ when Proc
99
+ list.detect &@pick
100
+ end
101
+ end
102
+
103
+ def picked_matching_file?
104
+ !!@matching_file
105
+ end
106
+
107
+ def validate_file(data_file)
108
+ return errors.add :response, "No matching files" unless data_file
109
+ validate_file_newer(data_file)
110
+ validate_file_smaller(data_file)
111
+ validate_file_larger(data_file)
112
+ end
113
+
114
+ def validate_file_newer(data_file)
115
+ @data_time ||= data_file.mtime
116
+ if file_newer && @data_time < file_newer
117
+ errors.add :response, "No new files since #{file_newer}"
118
+ end
119
+ end
120
+
121
+ def validate_file_smaller(data_file)
122
+ @data_size ||= data_file.size
123
+ if file_smaller && @data_size > file_smaller
124
+ errors.add :response, "File size larger than #{file_smaller}"
125
+ end
126
+ end
127
+
128
+ def validate_file_larger(data_file)
129
+ @data_size ||= data_file.size
130
+ if file_larger && @data_size < file_larger
131
+ errors.add :response, "File size smaller than #{file_larger}"
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,205 @@
1
+ require 'stockboy/provider'
2
+ require 'net/ftp'
3
+
4
+ module Stockboy::Providers
5
+
6
+ # Get data from a remote FTP server
7
+ #
8
+ # Allows for selecting the appropriate file to be read from the given
9
+ # directory by glob pattern or regex pattern (glob string is more efficient
10
+ # for listing files from FTP). By default the +:last+ file in the list is
11
+ # used, but can be controlled by sorting and reducing with the {#pick}
12
+ # option.
13
+ #
14
+ # == Job template DSL
15
+ #
16
+ # provider :ftp do
17
+ # host 'ftp.example.com'
18
+ # username 'example'
19
+ # password '424242'
20
+ # file_dir 'data/daily'
21
+ # file_name /report-[0-9]+\.csv/
22
+ # pick ->(list) { list[-2] }
23
+ # end
24
+ #
25
+ class FTP < Stockboy::Provider
26
+
27
+ # @!group Options
28
+
29
+ # Host name or IP address for FTP server connection
30
+ #
31
+ # @!attribute [rw] host
32
+ # @return [String]
33
+ # @example
34
+ # host "ftp.example.com"
35
+ #
36
+ dsl_attr :host
37
+
38
+ # Use a passive or active connection
39
+ #
40
+ # @!attribute [rw] passive
41
+ # @return [Boolean]
42
+ # @example
43
+ # passive true
44
+ #
45
+ dsl_attr :passive
46
+
47
+ # User name for connection credentials
48
+ #
49
+ # @!attribute [rw] username
50
+ # @return [String]
51
+ # @example
52
+ # username "arthur"
53
+ #
54
+ dsl_attr :username
55
+
56
+ # Password for connection credentials
57
+ #
58
+ # @!attribute [rw] password
59
+ # @return [String]
60
+ # @example
61
+ # password "424242"
62
+ #
63
+ dsl_attr :password
64
+
65
+ # Use binary mode for file transfers
66
+ #
67
+ # @!attribute [rw] binary
68
+ # @return [Boolean]
69
+ # @example
70
+ # binary true
71
+ #
72
+ dsl_attr :binary
73
+
74
+ # @macro provider.file_options
75
+ dsl_attr :file_name
76
+ dsl_attr :file_dir
77
+ dsl_attr :file_newer, alias: :since
78
+ dsl_attr :file_smaller
79
+ dsl_attr :file_larger
80
+
81
+ # @macro provider.pick_option
82
+ dsl_attr :pick
83
+
84
+ # @!endgroup
85
+
86
+ # Initialize a new FTP provider
87
+ #
88
+ def initialize(opts={}, &block)
89
+ super(opts, &block)
90
+ @host = opts[:host]
91
+ @passive = opts[:passive]
92
+ @username = opts[:username]
93
+ @password = opts[:password]
94
+ @binary = opts[:binary]
95
+ @file_dir = opts[:file_dir]
96
+ @file_name = opts[:file_name]
97
+ @file_newer = opts[:file_newer]
98
+ @file_smaller = opts[:file_smaller]
99
+ @file_larger = opts[:file_larger]
100
+ @pick = opts[:pick] || :last
101
+ DSL.new(self).instance_eval(&block) if block_given?
102
+ end
103
+
104
+ def client
105
+ return yield @open_client if @open_client
106
+
107
+ Net::FTP.open(host, username, password) do |ftp|
108
+ ftp.binary = binary
109
+ ftp.passive = passive
110
+ ftp.chdir file_dir if file_dir
111
+ @open_client = ftp
112
+ response = yield ftp
113
+ @open_client = nil
114
+ response
115
+ end
116
+ rescue Net::FTPError => e
117
+ errors.add :response, e.message
118
+ logger.warn e.message
119
+ end
120
+
121
+ def matching_file
122
+ return @matching_file if @matching_file
123
+ client do |ftp|
124
+ file_listing = ftp.nlst.sort
125
+ @matching_file = pick_from file_listing.select(&file_name_matcher)
126
+ end
127
+ end
128
+
129
+ def delete_data
130
+ raise Stockboy::OutOfSequence, "must confirm #matching_file or calling #data" unless picked_matching_file?
131
+ client do |ftp|
132
+ logger.info "FTP deleting file #{host} #{file_dir}/#{matching_file}"
133
+ ftp.delete matching_file
134
+ matching_file
135
+ end
136
+ end
137
+
138
+ def clear
139
+ super
140
+ @matching_file = nil
141
+ @data_time = nil
142
+ @data_size = nil
143
+ end
144
+
145
+ private
146
+
147
+ def fetch_data
148
+ client do |ftp|
149
+ validate_file(matching_file)
150
+ if valid?
151
+ logger.info "FTP getting file #{host} #{file_dir}/#{matching_file}"
152
+ @data = ftp.get(matching_file, nil)
153
+ logger.info "FTP got file #{host} #{file_dir}/#{matching_file} (#{@data_size} bytes)"
154
+ end
155
+ end
156
+ !@data.nil?
157
+ end
158
+
159
+ def picked_matching_file?
160
+ !!@matching_file
161
+ end
162
+
163
+ def validate
164
+ errors.add_on_blank [:host, :file_name]
165
+ errors.empty?
166
+ end
167
+
168
+ def file_name_matcher
169
+ case file_name
170
+ when Regexp
171
+ ->(i) { i =~ file_name }
172
+ when String
173
+ ->(i) { ::File.fnmatch(file_name, i) }
174
+ end
175
+ end
176
+
177
+ def validate_file(data_file)
178
+ return errors.add :response, "No matching files" unless data_file
179
+ validate_file_newer(data_file)
180
+ validate_file_smaller(data_file)
181
+ validate_file_larger(data_file)
182
+ end
183
+
184
+ def validate_file_newer(data_file)
185
+ @data_time ||= client { |ftp| ftp.mtime(data_file) }
186
+ if file_newer and @data_time < file_newer
187
+ errors.add :response, "No new files since #{file_newer}"
188
+ end
189
+ end
190
+
191
+ def validate_file_smaller(data_file)
192
+ @data_size ||= client { |ftp| ftp.size(data_file) }
193
+ if file_smaller and @data_size > file_smaller
194
+ errors.add :response, "File size larger than #{file_smaller}"
195
+ end
196
+ end
197
+
198
+ def validate_file_larger(data_file)
199
+ @data_size ||= client { |ftp| ftp.size(data_file) }
200
+ if file_larger and @data_size < file_larger
201
+ errors.add :response, "File size smaller than #{file_larger}"
202
+ end
203
+ end
204
+ end
205
+ end