zendeskdumper 0.0.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 793dbc9da00104153184bcee327a99e11fd0c4c4
4
- data.tar.gz: a7f86221af7f332c3d68d832e32c6cc721525c90
3
+ metadata.gz: 337035df0e670d759100d5031ebd1b65c1585cc5
4
+ data.tar.gz: 59e5ff9c8a89cafdc04ba926ac37381135ddab10
5
5
  SHA512:
6
- metadata.gz: 7e25519285a8e4c56e2d4e9708b5f45db8f6f07cba0b795507e024c8a005cd60c5be7f7068ead15935c6645b50d011f9273ea88109254f525c5ba605a2c9ce63
7
- data.tar.gz: 20028948fabf596fc9f121123ba7ccdcfc7243fa47bb5fa48d66affa944ba1cb17bf74e150fcc24edef2c2af54c4878cd5f0ca141b93aaeabe001900b39f5290
6
+ metadata.gz: 197f764b04756f3ea00f909132c4f3579415bb132da29904d2ce2d505741fa558d08947c1764d35416d0d0c75a1df2d91816019fbc9eea7cf6fa439a39a6786f
7
+ data.tar.gz: ce7cf9fac323826ba1fb85fa09988055c16654ba80203b7d4a5b0a36c92884631bdc25c8d8f3c08d0456ccae541a277c9c8fe11b1959d9f12b142ab7a7805d94
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env ruby
2
+ # Copyright © 2017 Taylor C. Richberger <taywee@gmx.com>
3
+ # This code is released under the license described in the LICENSE file
4
+
5
+ require 'logger'
6
+ require 'optparse'
7
+ require 'pathname'
8
+ require 'set'
9
+ require 'yaml'
10
+
11
+ require 'zendeskdumper'
12
+ require 'zendeskdumper/meta'
13
+
14
+
15
+ def main!
16
+ options = {directory: Pathname.new('.')}
17
+
18
+ opts = OptionParser.new
19
+ def opts.abort(output, code: 1)
20
+ STDERR.puts "ERROR: #{output}"
21
+ STDERR.puts self
22
+ exit code
23
+ end
24
+
25
+ opts.banner = 'Usage: zendeskdumper [options] DOMAIN'
26
+ opts.version = ZenDeskDumper::Meta::VERSION
27
+
28
+ opts.on_tail('-h', '--help', 'Show this help message') do
29
+ puts opts
30
+ exit
31
+ end
32
+ opts.on_tail('-V', '--version', 'Show the version') do
33
+ puts opts.version
34
+ exit
35
+ end
36
+
37
+ opts.on('-u', '--user USERNAME:PASSWORD', 'Specify username and password') do |user|
38
+ unless user.include? ':'
39
+ opts.abort '-u, --user needs at least one colon'
40
+ end
41
+
42
+ options.update([:username, :password].zip(user.split(':', 2)).to_h)
43
+ end
44
+ opts.on('-c', '--credentials FILE', 'Credentials YAML file with username and password fields') do |filename|
45
+ set = Set[:username, :password]
46
+ # Should only update username and password
47
+ options.update(YAML.load_file(filename).select {|key| set.include? key})
48
+ end
49
+ opts.on('-d', '--directory DIR', 'Directory to dump the files (default: .)') do |dir|
50
+ options[:directory] = Pathname.new(dir)
51
+ end
52
+ opts.parse!
53
+
54
+ if ARGV.empty?
55
+ opts.abort 'ERROR: DOMAIN is mandatory'
56
+ end
57
+
58
+ unless options.key? :username and options.key? :password
59
+ opts.abort 'Credentials must be specified with either -u or -c, and must specify username and password'
60
+ end
61
+
62
+ domain = ARGV.pop
63
+
64
+ logger = Logger.new(STDERR)
65
+
66
+ ZenDeskDumper::Dumper.new(
67
+ username: options[:username],
68
+ password: options[:password],
69
+ domain: domain,
70
+ ).dump do |filename, content|
71
+ path = options[:directory] + filename
72
+ dir = path.dirname
73
+ unless dir.directory?
74
+ logger.info "creating #{dir}"
75
+ dir.mkpath
76
+ end
77
+ logger.info "dumping #{path}"
78
+ File::write(path, content)
79
+ end
80
+ end
81
+
82
+
83
+ main! if __FILE__ == $0
@@ -0,0 +1,212 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'pathname'
4
+
5
+ module ZenDeskDumper
6
+ # A special dumper class. The "dump" method will use a passed-in block to
7
+ # generate, that way it can actually be used to directly write into an archive
8
+ # or something of the sort if desired. All path names are kept relative
9
+ class Dumper
10
+ attr_accessor :domain, :username, :password
11
+
12
+ def initialize(domain:, username:, password:)
13
+ @logger = Logger.new(STDERR)
14
+
15
+ @domain = domain
16
+ @username = username
17
+ @password = password
18
+
19
+ @threadlimit = 50
20
+ end
21
+
22
+ # Main method to call for this type; runs the full dumper. Uses a passed-in
23
+ # block to generate all files.
24
+ def dump(&block)
25
+ @logger.info 'pulling users'
26
+ dump_users(&block)
27
+ @logger.info 'pulling organizations'
28
+ dump_organizations(&block)
29
+ @logger.info 'pulling tickets'
30
+ dump_tickets(&block)
31
+ end
32
+
33
+ # Run a list of items, yield to the block, and join on the thread limit (and
34
+ # before finishing
35
+ def checkthreads(list)
36
+ threads = []
37
+ list.each do |item|
38
+ threads << Thread.new do
39
+ yield item
40
+ end
41
+ if threads.size >= @threadlimit
42
+ threads.each(&:join)
43
+ threads = []
44
+ end
45
+ end
46
+ threads.each(&:join)
47
+ end
48
+
49
+ # Get method which runs requests and optionally sleeps if necessary based on
50
+ # ratelimiting
51
+ def get(uri)
52
+ request = Net::HTTP::Get.new uri
53
+ request.basic_auth(@username, @password)
54
+ response = Net::HTTP::start(uri.host, uri.port, use_ssl: true) do |http|
55
+ http.request request
56
+ end
57
+ if Integer(response.code) == 429
58
+ # Make sure to allow some decent grace period
59
+ time = Integer(response['Retry-After']) + 60
60
+ @logger.debug "Hitting rate limiting, sleeping for #{time} seconds"
61
+ sleep time
62
+ return get uri
63
+ end
64
+ # Raise errors when necessary
65
+ response.value
66
+ response
67
+ end
68
+
69
+ # Get an attachment file, following redirects, and return its body
70
+ def get_attachment(uri, limit=10)
71
+ raise ArgumentError, 'too many redirects' if limit == 0
72
+
73
+ request = Net::HTTP::Get.new uri
74
+ @logger.debug "getting attachment from uri #{uri}"
75
+ request.basic_auth(@username, @password)
76
+ response = Net::HTTP::start(uri.host, uri.port, use_ssl: true) do |http|
77
+ http.request request
78
+ end
79
+ if response.is_a? Net::HTTPRedirection
80
+ @logger.debug "redirecting to #{response['location']}"
81
+ return get_attachment URI(response['location']), limit - 1
82
+ end
83
+ response.value
84
+ response.body
85
+ end
86
+
87
+ # get all pages from a paged api and yield their parsed JSON body into a block
88
+ def getpages(uri)
89
+ pagenumber = 1
90
+ loop do
91
+ response = get uri
92
+ body = JSON.parse(response.body)
93
+ yield body, pagenumber
94
+ next_page = body['next_page']
95
+ break if next_page.nil?
96
+ # If the next_page was the same as this one, it will loop forever
97
+ next_uri = URI(next_page)
98
+ break if uri == next_uri
99
+
100
+ uri = next_uri
101
+ pagenumber += 1
102
+ end
103
+ end
104
+
105
+ def dumppages(uri, basepath, formatname)
106
+ getpages uri do |page, pagenum|
107
+ path = basepath + formatname % pagenum
108
+ yield path, JSON.fast_generate(page)
109
+ end
110
+ end
111
+
112
+ def dump_users(&block)
113
+ uri = URI('https:/api/v2/users.json')
114
+ uri.hostname = @domain
115
+ getpages uri do |page|
116
+ checkthreads(page['users']) do |user|
117
+ begin
118
+ dump_user(user['id'], &block)
119
+ rescue Net::HTTPServerException, Net::HTTPFatalError
120
+ @logger.error "could not find user #{user}"
121
+ end
122
+ end
123
+ end
124
+ end
125
+
126
+ def dump_user(id, &block)
127
+ # Get user meta details
128
+ uri = URI("https:/api/v2/users/#{id}.json")
129
+ uri.hostname = @domain
130
+ response = get uri
131
+ basepath = Pathname.new('users') + id.to_s
132
+ yield basepath + 'user.json', response.body
133
+
134
+ # Get user groups
135
+ uri = URI("https:/api/v2/users/#{id}/groups.json")
136
+ uri.hostname = @domain
137
+ dumppages(uri, basepath, 'groups-%03d.json', &block)
138
+ end
139
+
140
+ def dump_organizations(&block)
141
+ uri = URI('https:/api/v2/organizations.json')
142
+ uri.hostname = @domain
143
+ getpages uri do |page|
144
+ checkthreads(page['organizations']) do |organization|
145
+ begin
146
+ dump_organization(organization['id'], &block)
147
+ rescue Net::HTTPServerException, Net::HTTPFatalError
148
+ @logger.error "could not find organization #{organization}"
149
+ end
150
+ end
151
+ end
152
+ end
153
+
154
+ def dump_organization(id, &block)
155
+ # get organization meta details
156
+ uri = URI("https:/api/v2/organizations/#{id}.json")
157
+ uri.hostname = @domain
158
+ response = get uri
159
+ basepath = Pathname.new('organizations') + id.to_s
160
+ yield basepath + 'organization.json', response.body
161
+
162
+ # Get users
163
+ uri = URI("https:/api/v2/organizations/#{id}/users.json")
164
+ uri.hostname = @domain
165
+ dumppages(uri, basepath, 'users-%03d.json', &block)
166
+ end
167
+
168
+ def dump_tickets(&block)
169
+ uri = URI('https:/api/v2/incremental/tickets.json?start_time=0')
170
+ uri.hostname = @domain
171
+ getpages uri do |page|
172
+ checkthreads(page['tickets']) do |ticket|
173
+ begin
174
+ dump_ticket(ticket['id'], &block)
175
+ rescue Net::HTTPServerException, Net::HTTPFatalError
176
+ @logger.error "could not find ticket #{ticket}"
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ def dump_ticket(id)
183
+ # Yield base ticket details
184
+ uri = URI("https:/api/v2/tickets/#{id}.json")
185
+ uri.hostname = @domain
186
+ response = get uri
187
+ basepath = Pathname.new('tickets') + id.to_s
188
+ yield basepath + 'ticket.json', response.body
189
+
190
+ # Yield out all comments
191
+ uri = URI("https:/api/v2/tickets/#{id}/comments.json")
192
+ uri.hostname = @domain
193
+ getpages uri do |page, pagenum|
194
+ path = basepath + 'comments-%03d.json' % pagenum
195
+ yield path, JSON.fast_generate(page)
196
+ page['comments'].each do |comment|
197
+ comment['attachments'].each do |attachment|
198
+ basepath = Pathname.new('attachments') + attachment['id'].to_s
199
+ @logger.debug "getting attachment json file"
200
+ yield basepath + 'attachment.json', JSON.fast_generate(attachment)
201
+ begin
202
+ @logger.debug "getting attachment file #{basepath + 'files' + attachment['file_name']}"
203
+ yield basepath + 'files' + attachment['file_name'], get_attachment(URI(attachment['content_url']))
204
+ rescue Net::HTTPServerException, Net::HTTPFatalError
205
+ @logger.error "could not find attachment file #{attachment}"
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,20 @@
1
+ # This is simply here in case we want any of this data available from within the
2
+ # running library, especially for generating the useable command line
3
+ module ZenDeskDumper
4
+ module Meta
5
+ NAME = 'zendeskdumper'
6
+ VERSION = '0.1.0'
7
+ DATE = '2018-01-02'
8
+ SUMMARY = 'A simple dumper for a ZenDesk domain.'
9
+ DESCRIPTION = 'A simple dumper for a ZenDesk domain. Attempts to pull all users, tickets, comments, and attachments'
10
+ AUTHORS = ['Taylor C. Richberger']
11
+ EMAIL = 'tcr@absolute-performance.com'
12
+ FILES = [
13
+ 'bin/zendeskdumper',
14
+ 'lib/zendeskdumper.rb',
15
+ 'lib/zendeskdumper/meta.rb',
16
+ ]
17
+ HOMEPAGE = 'https://rubygems.org/gem/zendeskdumper'
18
+ LICENSE = 'MIT'
19
+ end
20
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zendeskdumper
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Taylor C. Richberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2017-12-28 00:00:00.000000000 Z
11
+ date: 2018-01-02 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A simple dumper for a ZenDesk domain. Attempts to pull all users, tickets,
14
14
  comments, and attachments
@@ -17,7 +17,9 @@ executables: []
17
17
  extensions: []
18
18
  extra_rdoc_files: []
19
19
  files:
20
+ - bin/zendeskdumper
20
21
  - lib/zendeskdumper.rb
22
+ - lib/zendeskdumper/meta.rb
21
23
  homepage: https://rubygems.org/gem/zendeskdumper
22
24
  licenses:
23
25
  - MIT