stockboy 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.rspec +5 -0
- data/.yardopts +7 -0
- data/CHANGELOG.md +24 -0
- data/Gemfile +12 -0
- data/Guardfile +10 -0
- data/LICENSE +21 -0
- data/README.md +293 -0
- data/Rakefile +30 -0
- data/lib/stockboy.rb +80 -0
- data/lib/stockboy/attribute.rb +11 -0
- data/lib/stockboy/attribute_map.rb +74 -0
- data/lib/stockboy/candidate_record.rb +130 -0
- data/lib/stockboy/configuration.rb +62 -0
- data/lib/stockboy/configurator.rb +176 -0
- data/lib/stockboy/dsl.rb +68 -0
- data/lib/stockboy/exceptions.rb +3 -0
- data/lib/stockboy/filter.rb +58 -0
- data/lib/stockboy/filter_chain.rb +41 -0
- data/lib/stockboy/filters.rb +11 -0
- data/lib/stockboy/filters/missing_email.rb +37 -0
- data/lib/stockboy/job.rb +241 -0
- data/lib/stockboy/mapped_record.rb +59 -0
- data/lib/stockboy/provider.rb +238 -0
- data/lib/stockboy/providers.rb +11 -0
- data/lib/stockboy/providers/file.rb +135 -0
- data/lib/stockboy/providers/ftp.rb +205 -0
- data/lib/stockboy/providers/http.rb +123 -0
- data/lib/stockboy/providers/imap.rb +290 -0
- data/lib/stockboy/providers/soap.rb +120 -0
- data/lib/stockboy/railtie.rb +28 -0
- data/lib/stockboy/reader.rb +59 -0
- data/lib/stockboy/readers.rb +11 -0
- data/lib/stockboy/readers/csv.rb +115 -0
- data/lib/stockboy/readers/fixed_width.rb +121 -0
- data/lib/stockboy/readers/spreadsheet.rb +144 -0
- data/lib/stockboy/readers/xml.rb +155 -0
- data/lib/stockboy/registry.rb +42 -0
- data/lib/stockboy/source_record.rb +43 -0
- data/lib/stockboy/string_pool.rb +35 -0
- data/lib/stockboy/template_file.rb +44 -0
- data/lib/stockboy/translations.rb +70 -0
- data/lib/stockboy/translations/boolean.rb +58 -0
- data/lib/stockboy/translations/date.rb +41 -0
- data/lib/stockboy/translations/decimal.rb +33 -0
- data/lib/stockboy/translations/default_empty_string.rb +38 -0
- data/lib/stockboy/translations/default_false.rb +41 -0
- data/lib/stockboy/translations/default_nil.rb +38 -0
- data/lib/stockboy/translations/default_true.rb +41 -0
- data/lib/stockboy/translations/default_zero.rb +41 -0
- data/lib/stockboy/translations/integer.rb +33 -0
- data/lib/stockboy/translations/string.rb +33 -0
- data/lib/stockboy/translations/time.rb +41 -0
- data/lib/stockboy/translations/uk_date.rb +51 -0
- data/lib/stockboy/translations/us_date.rb +51 -0
- data/lib/stockboy/translator.rb +66 -0
- data/lib/stockboy/version.rb +3 -0
- data/spec/fixtures/.gitkeep +0 -0
- data/spec/fixtures/files/a_garbage.csv +1 -0
- data/spec/fixtures/files/test_data-20120101.csv +1 -0
- data/spec/fixtures/files/test_data-20120202.csv +1 -0
- data/spec/fixtures/files/z_garbage.csv +1 -0
- data/spec/fixtures/jobs/test_job.rb +1 -0
- data/spec/fixtures/soap/get_list/fault.xml +8 -0
- data/spec/fixtures/soap/get_list/success.xml +18 -0
- data/spec/fixtures/spreadsheets/test_data.xls +0 -0
- data/spec/fixtures/spreadsheets/test_row_options.xls +0 -0
- data/spec/fixtures/xml/body.xml +14 -0
- data/spec/spec_helper.rb +28 -0
- data/spec/stockboy/attribute_map_spec.rb +59 -0
- data/spec/stockboy/attribute_spec.rb +11 -0
- data/spec/stockboy/candidate_record_spec.rb +150 -0
- data/spec/stockboy/configuration_spec.rb +28 -0
- data/spec/stockboy/configurator_spec.rb +127 -0
- data/spec/stockboy/filter_chain_spec.rb +40 -0
- data/spec/stockboy/filter_spec.rb +41 -0
- data/spec/stockboy/filters/missing_email_spec.rb +26 -0
- data/spec/stockboy/filters_spec.rb +38 -0
- data/spec/stockboy/job_spec.rb +238 -0
- data/spec/stockboy/mapped_record_spec.rb +30 -0
- data/spec/stockboy/provider_spec.rb +34 -0
- data/spec/stockboy/providers/file_spec.rb +116 -0
- data/spec/stockboy/providers/ftp_spec.rb +143 -0
- data/spec/stockboy/providers/http_spec.rb +94 -0
- data/spec/stockboy/providers/imap_spec.rb +76 -0
- data/spec/stockboy/providers/soap_spec.rb +107 -0
- data/spec/stockboy/providers_spec.rb +38 -0
- data/spec/stockboy/readers/csv_spec.rb +68 -0
- data/spec/stockboy/readers/fixed_width_spec.rb +52 -0
- data/spec/stockboy/readers/spreadsheet_spec.rb +121 -0
- data/spec/stockboy/readers/xml_spec.rb +94 -0
- data/spec/stockboy/readers_spec.rb +30 -0
- data/spec/stockboy/source_record_spec.rb +19 -0
- data/spec/stockboy/template_file_spec.rb +30 -0
- data/spec/stockboy/translations/boolean_spec.rb +48 -0
- data/spec/stockboy/translations/date_spec.rb +38 -0
- data/spec/stockboy/translations/decimal_spec.rb +23 -0
- data/spec/stockboy/translations/default_empty_string_spec.rb +32 -0
- data/spec/stockboy/translations/default_false_spec.rb +25 -0
- data/spec/stockboy/translations/default_nil_spec.rb +32 -0
- data/spec/stockboy/translations/default_true_spec.rb +25 -0
- data/spec/stockboy/translations/default_zero_spec.rb +32 -0
- data/spec/stockboy/translations/integer_spec.rb +22 -0
- data/spec/stockboy/translations/string_spec.rb +22 -0
- data/spec/stockboy/translations/time_spec.rb +27 -0
- data/spec/stockboy/translations/uk_date_spec.rb +37 -0
- data/spec/stockboy/translations/us_date_spec.rb +37 -0
- data/spec/stockboy/translations_spec.rb +55 -0
- data/spec/stockboy/translator_spec.rb +27 -0
- data/stockboy.gemspec +32 -0
- 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,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
|