appcues_data_uploader 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 1f37ccc95281d68b9a4f6dcd313edc31ad3731c8
4
+ data.tar.gz: b1290ef67cbcdb17896b5611967a8e1146f04502
5
+ SHA512:
6
+ metadata.gz: 6352fbd7d9ded12a104dfc61e442bfa8ffb30ce7a7dc17cf7bfaed94a4906d279187c0d2542ed6db9673fddbd0dc61c616e841ce170c828309d9bc339706370f
7
+ data.tar.gz: 825cdd5adfc0a52489252346a3e78b6683ffd9d751ada29539cf44d5b9f43cd18610b0093faa53d42079102b47d6c5020ec4ec2c22c42fcb59d2e37255d2b8a7
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env -S ruby -Ilib
2
+ require 'appcues_data_uploader'
3
+ AppcuesDataUploader.main(ARGV)
4
+
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # Appcues Data Uploader
4
+ #
5
+ # Uploads CSV-formatted user profile data to the Appcues API.
6
+ # Run `appcues-data-uploader -h` for more information.
7
+ #
8
+ # Homepage: https://github.com/appcues/data-uploader
9
+ #
10
+ # Copyright 2018, Appcues, Inc.
11
+ #
12
+ # Released under the MIT License, whose text is available at:
13
+ # https://opensource.org/licenses/MIT
14
+
15
+ require 'net/http'
16
+ require 'csv'
17
+ require 'json'
18
+ require 'optparse'
19
+ require 'appcues_data_uploader/version'
20
+
21
+ class AppcuesDataUploader
22
+ UserActivity = Struct.new(:account_id, :user_id, :profile_update, :events)
23
+ UploadOpts = Struct.new(:account_id, :csv_filenames, :quiet, :dry_run)
24
+
25
+ attr_reader :opts
26
+
27
+ class << self
28
+ ## Handles command-line invocation.
29
+ def main(argv)
30
+ options = UploadOpts.new
31
+
32
+ option_parser = OptionParser.new do |opts|
33
+ opts.banner = <<-EOT
34
+ Usage: #{$0} [options] -a account_id [filename ...]
35
+
36
+ Uploads profile data from one or more CSVs to the Appcues API.
37
+ If no filename or a filename of '-' is given, STDIN is used.
38
+
39
+ Each CSV should start with a row of header names, including one named something
40
+ like "user ID". Other headers will be used verbatim as attribute names.
41
+
42
+ Attribute values can be boolean ('true' or 'false'), 'null', numeric, or
43
+ string-typed.
44
+
45
+ For example, giving `appcues-data-uploader -a 999` the following CSV data:
46
+
47
+ user_id,first_name,has_posse,height_in_inches
48
+ 123,Pete,false,68.5
49
+ 456,André,true,88
50
+
51
+ Will result in two profile updates being sent to the API:
52
+
53
+ {"account_id": "999", "user_id": "123", "profile_update": {"first_name": "Pete", "has_posse": false, "height_in_inches": 68.5}}
54
+ {"account_id": "999", "user_id": "456", "profile_update": {"first_name": "André", "has_posse": true, "height_in_inches": 88}}
55
+ EOT
56
+
57
+ opts.separator ""
58
+ opts.separator "Options:"
59
+
60
+ opts.on('-a', '--account-id ACCOUNT_ID', 'Set Appcues account ID') do |account_id|
61
+ options.account_id = account_id
62
+ end
63
+
64
+ opts.on('-d', '--dry-run', 'Write requests to STDOUT instead of sending') do
65
+ options.dry_run = true
66
+ end
67
+
68
+ opts.on('-q', '--quiet', "Don't write debugging info to STDERR") do
69
+ options.quiet = true
70
+ end
71
+
72
+ opts.on('-v', '--version', "Print version information and exit") do
73
+ puts "appcues-data-uploader version #{VERSION} (#{VERSION_DATE})"
74
+ puts "See https://github.com/appcues/data-uploader for more information."
75
+ exit
76
+ end
77
+
78
+ opts.on('-h', '--help', 'Print this message and exit') do
79
+ puts opts
80
+ exit
81
+ end
82
+ end
83
+
84
+ csv_filenames = option_parser.parse(argv)
85
+ csv_filenames = ["-"] if csv_filenames == []
86
+ options.csv_filenames = csv_filenames
87
+
88
+ if !options.account_id
89
+ STDERR.puts "You must specify an account ID with the -a option."
90
+ exit 1
91
+ end
92
+
93
+ new(options).perform_uploads()
94
+ end
95
+ end
96
+
97
+ def initialize(init_opts)
98
+ @opts = init_opts.is_a?(UploadOpts) ? init_opts : UploadOpts.new(
99
+ init_opts[:account_id] || init_opts["account_id"],
100
+ init_opts[:csv_filenames] || init_opts["csv_filenames"],
101
+ init_opts[:quiet] || init_opts["quiet"],
102
+ init_opts[:dry_run] || init_opts["dry_run"],
103
+ )
104
+
105
+ if !opts.account_id
106
+ raise ArgumentError, "account_id is required but missing"
107
+ end
108
+
109
+ if !opts.csv_filenames
110
+ raise ArgumentError, "csv_filenames must be a list of filenames"
111
+ end
112
+ end
113
+
114
+ def perform_uploads
115
+ opts.csv_filenames.each do |filename|
116
+ upload_profile_csv(filename)
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ ## Uploads the profile data in the given CSV to the Appcues API.
123
+ ##
124
+ ## The CSV should begin with a row of headers, and one of these headers
125
+ ## must be named something like `user_id` or `userId`.
126
+ ## Other header names are treated as attribute names.
127
+ ##
128
+ ## Numeric, boolean, and null values in this CSV will be converted to their
129
+ ## appropriate data type.
130
+ def upload_profile_csv(csv_filename)
131
+ display_filename = csv_filename == '-' ? "STDIN" : "'#{csv_filename}'"
132
+ input_fh = csv_filename == '-' ? STDIN : File.open(csv_filename, 'r')
133
+
134
+ debug "Uploading profiles from #{display_filename} for account #{opts.account_id}..."
135
+
136
+ user_id_column = nil
137
+ user_activities = []
138
+
139
+ CSV.new(input_fh, headers: true).each do |row|
140
+ row_hash = row.to_h
141
+
142
+ if !user_id_column
143
+ user_id_column = get_user_id_column(row_hash)
144
+ end
145
+
146
+ user_id = row_hash.delete(user_id_column)
147
+ profile_update = cast_data_types(row_hash)
148
+
149
+ user_activities << UserActivity.new(opts.account_id, user_id, profile_update, [])
150
+ end
151
+
152
+ input_fh.close
153
+
154
+ if opts.dry_run
155
+ user_activities.each do |ua|
156
+ puts JSON.dump(ua.to_h)
157
+ end
158
+ else
159
+ make_activity_requests(user_activities)
160
+ end
161
+
162
+ debug "Done processing #{display_filename}."
163
+ end
164
+
165
+ ## Applies the given UserActivity updates to the Appcues API.
166
+ ## Retries failed requests, indefinitely.
167
+ def make_activity_requests(user_activities)
168
+ failed_uas = []
169
+
170
+ user_activities.each do |ua|
171
+ resp = make_activity_request(ua)
172
+ if resp.code.to_i / 100 == 2
173
+ debug "Request for user_id #{ua.user_id} was successful"
174
+ else
175
+ debug "Request for user_id #{ua.user_id} failed with code #{resp.code} -- retrying later"
176
+ failed_uas << ua
177
+ end
178
+ end
179
+
180
+ if failed_uas.count > 0
181
+ debug "retrying #{failed_uas.count} requests."
182
+ make_activity_requests(failed_uas)
183
+ end
184
+ end
185
+
186
+ ## Returns a new profile_update hash where boolean and numeric values
187
+ ## are cast out of String format. Leaves other values alone.
188
+ def cast_data_types(profile_update)
189
+ output = {}
190
+ profile_update.each do |key, value|
191
+ output[key] =
192
+ case value
193
+ when 'null'
194
+ nil
195
+ when 'true'
196
+ true
197
+ when 'false'
198
+ false
199
+ when /^ -? \d* \. \d+ (?: [eE] [+-]? \d+)? $/x # float
200
+ value.to_f
201
+ when /^ -? \d+ $/x # integer
202
+ value.to_i
203
+ else
204
+ value
205
+ end
206
+ end
207
+ output
208
+ end
209
+
210
+ ## Detects and returns the name used in the CSV header to identify user ID.
211
+ ## Raises an exception if we can't find it.
212
+ def get_user_id_column(row_hash)
213
+ row_hash.keys.each do |key|
214
+ canonical_key = key.gsub(/[^a-zA-Z]/, '').downcase
215
+ return key if canonical_key == 'userid'
216
+ end
217
+ raise ArgumentError, "couldn't detect user ID column"
218
+ end
219
+
220
+ ## Prints a message to STDERR unless we're in quiet mode.
221
+ def debug(msg)
222
+ STDERR.puts(msg) unless self.opts.quiet
223
+ end
224
+
225
+ ## Returns the base URL for the Appcues API.
226
+ def appcues_api_url
227
+ ENV['APPCUES_API_URL'] || "https://api.appcues.com"
228
+ end
229
+
230
+ ## Returns a URL for the given Appcues API UserActivity endpoint.
231
+ def activity_url(account_id, user_id)
232
+ "#{appcues_api_url}/v1/accounts/#{account_id}/users/#{user_id}/activity"
233
+ end
234
+
235
+ ## Makes a POST request to the Appcues API UserActivity endpoint,
236
+ ## returning the Net::HTTPResponse object.
237
+ def make_activity_request(user_activity)
238
+ url = activity_url(user_activity.account_id, user_activity.user_id)
239
+ post_request(url, {
240
+ "profile_update" => user_activity.profile_update,
241
+ "events" => user_activity.events
242
+ })
243
+ end
244
+
245
+ ## Makes a POST request to the given URL,
246
+ ## returning the Net::HTTPResponse object.
247
+ def post_request(url, data, headers = {})
248
+ uri = URI(url)
249
+ use_ssl = uri.scheme == 'https'
250
+ Net::HTTP.start(uri.host, uri.port, use_ssl: use_ssl) do |http|
251
+ req_headers = headers.merge({'Content-type' => 'application/json'})
252
+ req = Net::HTTP::Post.new(uri.request_uri, req_headers)
253
+ req.body = JSON.dump(data)
254
+ http.request(req)
255
+ end
256
+ end
257
+ end
258
+
@@ -0,0 +1,4 @@
1
+ class AppcuesDataUploader
2
+ VERSION = "0.1.0"
3
+ VERSION_DATE = "2018-11-08"
4
+ end
metadata ADDED
@@ -0,0 +1,47 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: appcues_data_uploader
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - pete gamache
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-11-08 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email: pete@gamache.org
15
+ executables:
16
+ - appcues-data-uploader
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - bin/appcues-data-uploader
21
+ - lib/appcues_data_uploader.rb
22
+ - lib/appcues_data_uploader/version.rb
23
+ homepage: https://github.com/appcues/data-uploader
24
+ licenses:
25
+ - MIT
26
+ metadata: {}
27
+ post_install_message:
28
+ rdoc_options: []
29
+ require_paths:
30
+ - lib
31
+ required_ruby_version: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: '2.0'
36
+ required_rubygems_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ requirements: []
42
+ rubyforge_project:
43
+ rubygems_version: 2.6.14.3
44
+ signing_key:
45
+ specification_version: 4
46
+ summary: Upload CSVs of user profile data to the Appcues API
47
+ test_files: []