zendeskdumper 0.0.0 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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