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 +7 -0
- data/bin/appcues-data-uploader +4 -0
- data/lib/appcues_data_uploader.rb +258 -0
- data/lib/appcues_data_uploader/version.rb +4 -0
- metadata +47 -0
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,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
|
+
|
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: []
|