class AppcuesDataUploader

Constants

UploadOpts
UserActivity
VERSION
VERSION_DATE

Attributes

opts[R]

Public Class Methods

main(argv) click to toggle source

Handles command-line invocation.

# File lib/appcues_data_uploader.rb, line 29
    def main(argv)
      options = UploadOpts.new

      option_parser = OptionParser.new do |opts|
        opts.banner =  <<-EOT
Usage: appcues-data-uploader [options] -a account_id [filename ...]

Uploads profile data from one or more CSVs to the Appcues API.
If no filename or a filename of '-' is given, STDIN is used.

Each CSV should start with a row of header names, including one named something
like "user ID". Other headers will be used verbatim as attribute names.

Attribute values can be boolean ('true' or 'false'), 'null', numeric, or
string-typed.

For example, giving `appcues-data-uploader -a 999` the following CSV data:

    user_id,first_name,has_posse,height_in_inches
    123,Pete,false,68.5
    456,André,true,88

Will result in two profile updates being sent to the API:

    {"account_id": "999", "user_id": "123", "profile_update": {"first_name": "Pete", "has_posse": false, "height_in_inches": 68.5}}
    {"account_id": "999", "user_id": "456", "profile_update": {"first_name": "André", "has_posse": true, "height_in_inches": 88}}

See https://github.com/appcues/data-uploader for more information.
        EOT

        opts.separator ""
        opts.separator "Options:"

        opts.on('-a', '--account-id ACCOUNT_ID', 'Set Appcues account ID') do |account_id|
          options.account_id = account_id
        end

        opts.on('-d', '--dry-run', 'Write requests to STDOUT instead of sending') do
          options.dry_run = true
        end

        opts.on('-q', '--quiet', "Don't write debugging info to STDERR") do
          options.quiet = true
        end

        opts.on('-v', '--version', "Print version information and exit") do
          puts "appcues-data-uploader version #{VERSION} (#{VERSION_DATE})"
          puts "See https://github.com/appcues/data-uploader for more information."
          exit
        end

        opts.on('-h', '--help', 'Print this message and exit') do
          puts opts
          exit
        end
      end

      csv_filenames = option_parser.parse(argv)
      csv_filenames = ["-"] if csv_filenames == []
      options.csv_filenames = csv_filenames

      if !options.account_id
        STDERR.puts "You must specify an account ID with the -a option."
        STDERR.puts "Run `appcues-data-uploader --help` for more information."
        exit 1
      end

      begin
        new(options).perform_uploads()
      rescue Exception => e
        STDERR.puts "#{e.class}: #{e.message}"
        exit 255
      end
    end
new(init_opts) click to toggle source
# File lib/appcues_data_uploader.rb, line 105
def initialize(init_opts)
  @opts = init_opts.is_a?(UploadOpts) ? init_opts : UploadOpts.new(
    init_opts[:account_id] || init_opts["account_id"],
    init_opts[:csv_filenames] || init_opts["csv_filenames"],
    init_opts[:quiet] || init_opts["quiet"],
    init_opts[:dry_run] || init_opts["dry_run"],
  )

  if !opts.account_id
    raise ArgumentError, "account_id is required but missing"
  end

  if !opts.csv_filenames
    raise ArgumentError, "csv_filenames must be a list of filenames"
  end
end

Public Instance Methods

perform_uploads() click to toggle source
# File lib/appcues_data_uploader.rb, line 122
def perform_uploads
  opts.csv_filenames.each do |filename|
    upload_profile_csv(filename)
  end
end

Private Instance Methods

activity_url(account_id, user_id) click to toggle source

Returns a URL for the given Appcues API UserActivity endpoint.

# File lib/appcues_data_uploader.rb, line 239
def activity_url(account_id, user_id)
  "#{appcues_api_url}/v1/accounts/#{account_id}/users/#{user_id}/activity"
end
appcues_api_url() click to toggle source

Returns the base URL for the Appcues API.

# File lib/appcues_data_uploader.rb, line 234
def appcues_api_url
  ENV['APPCUES_API_URL'] || "https://api.appcues.com"
end
cast_data_types(profile_update) click to toggle source

Returns a new profile_update hash where boolean and numeric values are cast out of String format. Leaves other values alone.

# File lib/appcues_data_uploader.rb, line 196
def cast_data_types(profile_update)
  output = {}
  profile_update.each do |key, value|
    output[key] =
      case value
      when 'null'
        nil
      when 'true'
        true
      when 'false'
        false
      when /^ -? \d* \. \d+ (?: [eE] [+-]? \d+)? $/x  # float
        value.to_f
      when /^ -? \d+ $/x  # integer
        value.to_i
      else
        value
      end
  end
  output
end
debug(msg) click to toggle source

Prints a message to STDERR unless we're in quiet mode.

# File lib/appcues_data_uploader.rb, line 229
def debug(msg)
  STDERR.puts(msg) unless self.opts.quiet
end
get_user_id_column(row_hash) click to toggle source

Detects and returns the name used in the CSV header to identify user ID. Raises an exception if we can't find it.

# File lib/appcues_data_uploader.rb, line 220
def get_user_id_column(row_hash)
  row_hash.keys.each do |key|
    canonical_key = key.gsub(/[^a-zA-Z]/, '').downcase
    return key if canonical_key == 'userid'
  end
  raise "Couldn't detect user ID column from CSV input. Ensure that the CSV data starts with headers, and one is named like 'user_id'."
end
make_activity_request(user_activity) click to toggle source

Makes a POST request to the Appcues API UserActivity endpoint, returning the Net::HTTPResponse object.

# File lib/appcues_data_uploader.rb, line 245
def make_activity_request(user_activity)
  url = activity_url(user_activity.account_id, user_activity.user_id)
  post_request(url, {
    "profile_update" => user_activity.profile_update,
    "events" => user_activity.events
  })
end
make_activity_requests(user_activities) click to toggle source

Applies the given UserActivity updates to the Appcues API. Retries failed requests, indefinitely.

# File lib/appcues_data_uploader.rb, line 175
def make_activity_requests(user_activities)
  failed_uas = []

  user_activities.each do |ua|
    resp = make_activity_request(ua)
    if resp.code.to_i / 100 == 2
      debug "Request for user_id #{ua.user_id} was successful"
    else
      debug "Request for user_id #{ua.user_id} failed with code #{resp.code} -- retrying later"
      failed_uas << ua
    end
  end

  if failed_uas.count > 0
    debug "Retrying #{failed_uas.count} requests."
    make_activity_requests(failed_uas)
  end
end
post_request(url, data, headers = {}) click to toggle source

Makes a POST request to the given URL, returning the Net::HTTPResponse object.

# File lib/appcues_data_uploader.rb, line 255
def post_request(url, data, headers = {})
  uri = URI(url)
  use_ssl = uri.scheme == 'https'
  Net::HTTP.start(uri.host, uri.port, use_ssl: use_ssl) do |http|
    req_headers = headers.merge({'Content-type' => 'application/json'})
    req = Net::HTTP::Post.new(uri.request_uri, req_headers)
    req.body = JSON.dump(data)
    http.request(req)
  end
end
upload_profile_csv(csv_filename) click to toggle source

Uploads the profile data in the given CSV to the Appcues API.

The CSV should begin with a row of headers, and one of these headers must be named something like `user_id` or `userId`. Other header names are treated as attribute names.

Numeric, boolean, and null values in this CSV will be converted to their appropriate data type.

# File lib/appcues_data_uploader.rb, line 138
def upload_profile_csv(csv_filename)
  display_filename = csv_filename == '-' ? "STDIN" : "'#{csv_filename}'"
  input_fh = csv_filename == '-' ? STDIN : File.open(csv_filename, 'r')

  debug "Uploading profiles from #{display_filename} for account #{opts.account_id}..."

  user_id_column = nil
  user_activities = []

  CSV.new(input_fh, headers: true).each do |row|
    row_hash = row.to_hash

    if !user_id_column
      user_id_column = get_user_id_column(row_hash)
    end

    user_id = row_hash.delete(user_id_column)
    profile_update = cast_data_types(row_hash)

    user_activities << UserActivity.new(opts.account_id, user_id, profile_update, [])
  end

  input_fh.close

  if opts.dry_run
    user_activities.each do |ua|
      puts JSON.dump(ua.to_h)
    end
  else
    make_activity_requests(user_activities)
  end

  debug "Done processing #{display_filename}."
end