discourse_zendesk_api 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +176 -0
  3. data/lib/zendesk_api/actions.rb +334 -0
  4. data/lib/zendesk_api/association.rb +195 -0
  5. data/lib/zendesk_api/associations.rb +212 -0
  6. data/lib/zendesk_api/client.rb +243 -0
  7. data/lib/zendesk_api/collection.rb +474 -0
  8. data/lib/zendesk_api/configuration.rb +79 -0
  9. data/lib/zendesk_api/core_ext/inflection.rb +3 -0
  10. data/lib/zendesk_api/delegator.rb +5 -0
  11. data/lib/zendesk_api/error.rb +49 -0
  12. data/lib/zendesk_api/helpers.rb +24 -0
  13. data/lib/zendesk_api/lru_cache.rb +39 -0
  14. data/lib/zendesk_api/middleware/request/encode_json.rb +26 -0
  15. data/lib/zendesk_api/middleware/request/etag_cache.rb +52 -0
  16. data/lib/zendesk_api/middleware/request/raise_rate_limited.rb +31 -0
  17. data/lib/zendesk_api/middleware/request/retry.rb +59 -0
  18. data/lib/zendesk_api/middleware/request/upload.rb +86 -0
  19. data/lib/zendesk_api/middleware/request/url_based_access_token.rb +26 -0
  20. data/lib/zendesk_api/middleware/response/callback.rb +21 -0
  21. data/lib/zendesk_api/middleware/response/deflate.rb +17 -0
  22. data/lib/zendesk_api/middleware/response/gzip.rb +19 -0
  23. data/lib/zendesk_api/middleware/response/logger.rb +44 -0
  24. data/lib/zendesk_api/middleware/response/parse_iso_dates.rb +30 -0
  25. data/lib/zendesk_api/middleware/response/parse_json.rb +23 -0
  26. data/lib/zendesk_api/middleware/response/raise_error.rb +26 -0
  27. data/lib/zendesk_api/middleware/response/sanitize_response.rb +11 -0
  28. data/lib/zendesk_api/resource.rb +208 -0
  29. data/lib/zendesk_api/resources.rb +971 -0
  30. data/lib/zendesk_api/sideloading.rb +58 -0
  31. data/lib/zendesk_api/silent_mash.rb +8 -0
  32. data/lib/zendesk_api/track_changes.rb +85 -0
  33. data/lib/zendesk_api/trackie.rb +13 -0
  34. data/lib/zendesk_api/verbs.rb +65 -0
  35. data/lib/zendesk_api/version.rb +3 -0
  36. data/lib/zendesk_api.rb +4 -0
  37. data/util/resource_handler.rb +74 -0
  38. data/util/verb_handler.rb +16 -0
  39. metadata +166 -0
@@ -0,0 +1,52 @@
1
+ require "faraday/middleware"
2
+
3
+ module ZendeskAPI
4
+ module Middleware
5
+ module Request
6
+ # Request middleware that caches responses based on etags
7
+ # can be removed once this is merged: https://github.com/pengwynn/faraday_middleware/pull/42
8
+ # @private
9
+ class EtagCache < Faraday::Middleware
10
+ def initialize(app, options = {})
11
+ @app = app
12
+ @cache = options[:cache] ||
13
+ raise("need :cache option e.g. ActiveSupport::Cache::MemoryStore.new")
14
+ @cache_key_prefix = options.fetch(:cache_key_prefix, :faraday_etags)
15
+ end
16
+
17
+ def cache_key(env)
18
+ [@cache_key_prefix, env[:url].to_s]
19
+ end
20
+
21
+ def call(environment)
22
+ return @app.call(environment) unless [:get, :head].include?(environment[:method])
23
+
24
+ # send known etag
25
+ cached = @cache.read(cache_key(environment))
26
+
27
+ if cached
28
+ environment[:request_headers]["If-None-Match"] ||= cached[:response_headers]["Etag"]
29
+ end
30
+
31
+ @app.call(environment).on_complete do |env|
32
+ if cached && env[:status] == 304 # not modified
33
+ # Handle differences in serialized env keys in Faraday < 1.0 and 1.0
34
+ # See https://github.com/lostisland/faraday/pull/847
35
+ env[:body] = cached[:body]
36
+ env[:response_body] = cached[:response_body]
37
+
38
+ env[:response_headers].merge!(
39
+ :etag => cached[:response_headers][:etag],
40
+ :content_type => cached[:response_headers][:content_type],
41
+ :content_length => cached[:response_headers][:content_length],
42
+ :content_encoding => cached[:response_headers][:content_encoding]
43
+ )
44
+ elsif env[:status] == 200 && env[:response_headers]["Etag"] # modified and cacheable
45
+ @cache.write(cache_key(env), env.to_hash)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,31 @@
1
+ require 'faraday/middleware'
2
+ require 'zendesk_api/error'
3
+
4
+ module ZendeskAPI
5
+ module Middleware
6
+ # @private
7
+ module Request
8
+ # Faraday middleware to handle HTTP Status 429 (rate limiting) / 503 (maintenance)
9
+ # @private
10
+ class RaiseRateLimited < Faraday::Middleware
11
+ ERROR_CODES = [429, 503].freeze
12
+
13
+ def initialize(app, options = {})
14
+ super(app)
15
+ @logger = options[:logger]
16
+ end
17
+
18
+ def call(env)
19
+ response = @app.call(env)
20
+
21
+ if ERROR_CODES.include?(response.env[:status])
22
+ @logger&.warn 'You have been rate limited. Raising error...'
23
+ raise Error::RateLimited, env
24
+ else
25
+ response
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,59 @@
1
+ require "faraday/middleware"
2
+ module ZendeskAPI
3
+ module Middleware
4
+ # @private
5
+ module Request
6
+ # Faraday middleware to handle HTTP Status 429 (rate limiting) / 503 (maintenance)
7
+ # @private
8
+ class Retry < Faraday::Middleware
9
+ DEFAULT_RETRY_AFTER = 10
10
+ DEFAULT_ERROR_CODES = [429, 503]
11
+
12
+ def initialize(app, options = {})
13
+ super(app)
14
+ @logger = options[:logger]
15
+ @error_codes = options.key?(:retry_codes) && options[:retry_codes] ? options[:retry_codes] : DEFAULT_ERROR_CODES
16
+ @retry_on_exception = options.key?(:retry_on_exception) && options[:retry_on_exception] ? options[:retry_on_exception] : false
17
+ end
18
+
19
+ def call(env)
20
+ original_env = env.dup
21
+ exception_happened = false
22
+ if @retry_on_exception
23
+ begin
24
+ response = @app.call(env)
25
+ rescue StandardError => e
26
+ exception_happened = true
27
+ end
28
+ else
29
+ response = @app.call(env)
30
+ end
31
+
32
+ if exception_happened || @error_codes.include?(response.env[:status])
33
+
34
+ if exception_happened
35
+ seconds_left = DEFAULT_RETRY_AFTER.to_i
36
+ @logger.warn "An exception happened, waiting #{seconds_left} seconds... #{e}" if @logger
37
+ else
38
+ seconds_left = (response.env[:response_headers][:retry_after] || DEFAULT_RETRY_AFTER).to_i
39
+ end
40
+
41
+ @logger.warn "You have been rate limited. Retrying in #{seconds_left} seconds..." if @logger
42
+
43
+ seconds_left.times do |i|
44
+ sleep 1
45
+ time_left = seconds_left - i
46
+ @logger.warn "#{time_left}..." if time_left > 0 && time_left % 5 == 0 && @logger
47
+ end
48
+
49
+ @logger.warn "" if @logger
50
+
51
+ @app.call(original_env)
52
+ else
53
+ response
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,86 @@
1
+ require "faraday/middleware"
2
+ require "mini_mime"
3
+ require 'tempfile'
4
+
5
+ module ZendeskAPI
6
+ module Middleware
7
+ module Request
8
+ # @private
9
+ class Upload < Faraday::Middleware
10
+ def call(env)
11
+ if env[:body]
12
+ set_file(env[:body], :file, true)
13
+ traverse_hash(env[:body])
14
+ end
15
+
16
+ @app.call(env)
17
+ end
18
+
19
+ private
20
+
21
+ # Sets the proper file parameters :uploaded_data and :filename
22
+ # If top_level, then it removes key and and sets the parameters directly on hash,
23
+ # otherwise it adds the parameters to hash[key]
24
+ def set_file(hash, key, top_level)
25
+ return unless hash.key?(key)
26
+
27
+ file = if hash[key].is_a?(Hash) && hash[key].key?(:file)
28
+ hash[key].delete(:file)
29
+ else
30
+ hash.delete(key)
31
+ end
32
+
33
+ case file
34
+ when File, Tempfile
35
+ path = file.path
36
+ when String
37
+ path = file
38
+ else
39
+ if defined?(ActionDispatch) && file.is_a?(ActionDispatch::Http::UploadedFile)
40
+ path = file.tempfile.path
41
+ mime_type = file.content_type
42
+ else
43
+ warn "WARNING: Passed invalid filename #{file} of type #{file.class} to upload"
44
+ end
45
+ end
46
+
47
+ if path
48
+ if !top_level
49
+ hash[key] ||= {}
50
+ hash = hash[key]
51
+ end
52
+
53
+ unless defined?(mime_type) && !mime_type.nil?
54
+ mime_type = MiniMime.lookup_by_filename(path)
55
+ mime_type = mime_type ? mime_type.content_type : "application/octet-stream"
56
+ end
57
+
58
+ hash[:filename] ||= if file.respond_to?(:original_filename)
59
+ file.original_filename
60
+ else
61
+ File.basename(path)
62
+ end
63
+
64
+ hash[:uploaded_data] = Faraday::UploadIO.new(path, mime_type, hash[:filename])
65
+ end
66
+ end
67
+
68
+ # Calls #set_file on File instances or Hashes
69
+ # of the format { :file => File (, :filename => ...) }
70
+ def traverse_hash(hash)
71
+ hash.keys.each do |key|
72
+ if hash[key].is_a?(File)
73
+ set_file(hash, key, false)
74
+ elsif hash[key].is_a?(Hash)
75
+ if hash[key].key?(:file)
76
+ set_file(hash, key, false)
77
+ else
78
+ traverse_hash(hash[key])
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,26 @@
1
+ module ZendeskAPI
2
+ # @private
3
+ module Middleware
4
+ # @private
5
+ module Request
6
+ class UrlBasedAccessToken < Faraday::Middleware
7
+ def initialize(app, token)
8
+ super(app)
9
+ @token = token
10
+ end
11
+
12
+ def call(env)
13
+ if env[:url].query
14
+ env[:url].query += '&'
15
+ else
16
+ env[:url].query = ''
17
+ end
18
+
19
+ env[:url].query += "access_token=#{@token}"
20
+
21
+ @app.call(env)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ require "faraday/response"
2
+
3
+ module ZendeskAPI
4
+ module Middleware
5
+ module Response
6
+ # @private
7
+ class Callback < Faraday::Response::Middleware
8
+ def initialize(app, client)
9
+ super(app)
10
+ @client = client
11
+ end
12
+
13
+ def call(env)
14
+ @app.call(env).on_complete do |env|
15
+ @client.callbacks.each { |c| c.call(env) }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ module ZendeskAPI
2
+ # @private
3
+ module Middleware
4
+ # @private
5
+ module Response
6
+ # Faraday middleware to handle content-encoding = inflate
7
+ # @private
8
+ class Deflate < Faraday::Response::Middleware
9
+ def on_complete(env)
10
+ if !env.body.strip.empty? && env[:response_headers]['content-encoding'] == "deflate"
11
+ env.body = Zlib::Inflate.inflate(env.body)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ require 'zlib'
2
+ require 'stringio'
3
+
4
+ module ZendeskAPI
5
+ # @private
6
+ module Middleware
7
+ # @private
8
+ module Response
9
+ # Faraday middleware to handle content-encoding = gzip
10
+ class Gzip < Faraday::Response::Middleware
11
+ def on_complete(env)
12
+ if !env[:body].strip.empty? && env[:response_headers]['content-encoding'] == "gzip"
13
+ env[:body] = Zlib::GzipReader.new(StringIO.new(env[:body])).read
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,44 @@
1
+ module ZendeskAPI
2
+ module Middleware
3
+ module Response
4
+ # Faraday middleware to handle logging
5
+ # @private
6
+ class Logger < Faraday::Middleware
7
+ LOG_LENGTH = 1000
8
+
9
+ def initialize(app, logger = nil)
10
+ super(app)
11
+
12
+ @logger = logger || begin
13
+ require 'logger'
14
+ ::Logger.new(STDOUT)
15
+ end
16
+ end
17
+
18
+ def call(env)
19
+ @logger.info "#{env[:method]} #{env[:url].to_s}"
20
+ @logger.debug dump_debug(env, :request_headers)
21
+
22
+ @app.call(env).on_complete do |env|
23
+ info = "Status #{env[:status]}"
24
+ info.concat(" #{env[:body].to_s[0, LOG_LENGTH]}") if (400..499).cover?(env[:status].to_i)
25
+
26
+ @logger.info info
27
+ @logger.debug dump_debug(env, :response_headers)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def dump_debug(env, headers_key)
34
+ info = env[headers_key].map { |k, v| " #{k}: #{v.inspect}" }.join("\n")
35
+ unless env[:body].nil?
36
+ info.concat("\n")
37
+ info.concat(env[:body].inspect)
38
+ end
39
+ info
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,30 @@
1
+ require 'time'
2
+ require "faraday/response"
3
+
4
+ module ZendeskAPI
5
+ module Middleware
6
+ module Response
7
+ # Parse ISO dates from response body
8
+ # @private
9
+ class ParseIsoDates < Faraday::Response::Middleware
10
+ def call(env)
11
+ @app.call(env).on_complete do |env|
12
+ parse_dates!(env[:body])
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def parse_dates!(value)
19
+ case value
20
+ when Hash then value.each { |key, element| value[key] = parse_dates!(element) }
21
+ when Array then value.each_with_index { |element, index| value[index] = parse_dates!(element) }
22
+ when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z/m then Time.parse(value)
23
+ else
24
+ value
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,23 @@
1
+ module ZendeskAPI
2
+ # @private
3
+ module Middleware
4
+ # @private
5
+ module Response
6
+ class ParseJson < Faraday::Response::Middleware
7
+ CONTENT_TYPE = 'Content-Type'.freeze
8
+ dependency 'json'
9
+
10
+ def on_complete(env)
11
+ type = env[:response_headers][CONTENT_TYPE].to_s
12
+ type = type.split(';', 2).first if type.index(';')
13
+
14
+ return unless type == 'application/json'
15
+
16
+ unless env[:body].strip.empty?
17
+ env[:body] = JSON.parse(env[:body])
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,26 @@
1
+ require 'zendesk_api/error'
2
+
3
+ module ZendeskAPI
4
+ module Middleware
5
+ module Response
6
+ class RaiseError < Faraday::Response::RaiseError
7
+ def call(env)
8
+ super
9
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
10
+ raise Error::NetworkError.new(e, env)
11
+ end
12
+
13
+ def on_complete(env)
14
+ case env[:status]
15
+ when 404
16
+ raise Error::RecordNotFound.new(env)
17
+ when 422, 413
18
+ raise Error::RecordInvalid.new(env)
19
+ when 100..199, 400..599, 300..303, 305..399
20
+ raise Error::NetworkError.new(env)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,11 @@
1
+ module ZendeskAPI
2
+ module Middleware
3
+ module Response
4
+ class SanitizeResponse < Faraday::Response::Middleware
5
+ def on_complete(env)
6
+ env[:body].scrub!('')
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,208 @@
1
+ require 'zendesk_api/helpers'
2
+ require 'zendesk_api/trackie'
3
+ require 'zendesk_api/actions'
4
+ require 'zendesk_api/association'
5
+ require 'zendesk_api/associations'
6
+ require 'zendesk_api/verbs'
7
+
8
+ module ZendeskAPI
9
+ # Represents a resource that only holds data.
10
+ class Data
11
+ include Associations
12
+
13
+ class << self
14
+ def inherited(klass)
15
+ subclasses.push(klass)
16
+ end
17
+
18
+ def subclasses
19
+ @subclasses ||= []
20
+ end
21
+
22
+ # The singular resource name taken from the class name (e.g. ZendeskAPI::Ticket -> ticket)
23
+ def singular_resource_name
24
+ @singular_resource_name ||= ZendeskAPI::Helpers.snakecase_string(to_s.split("::").last)
25
+ end
26
+
27
+ # The resource name taken from the class name (e.g. ZendeskAPI::Ticket -> tickets)
28
+ def resource_name
29
+ @resource_name ||= Inflection.plural(singular_resource_name)
30
+ end
31
+
32
+ def resource_path
33
+ [@namespace, resource_name].compact.join("/")
34
+ end
35
+
36
+ alias :model_key :resource_name
37
+
38
+ def namespace(namespace)
39
+ @namespace = namespace
40
+ end
41
+ end
42
+
43
+ # @return [Hash] The resource's attributes
44
+ attr_reader :attributes
45
+ # @return [ZendeskAPI::Association] The association
46
+ attr_accessor :association
47
+ # @return [Array] The last received errors
48
+ attr_accessor :errors
49
+ # Place to dump the last response
50
+ attr_accessor :response
51
+
52
+ # Create a new resource instance.
53
+ # @param [Client] client The client to use
54
+ # @param [Hash] attributes The optional attributes that describe the resource
55
+ def initialize(client, attributes = {})
56
+ raise "Expected a Hash for attributes, got #{attributes.inspect}" unless attributes.is_a?(Hash)
57
+ @association = attributes.delete(:association) || Association.new(:class => self.class)
58
+ @global_params = attributes.delete(:global) || {}
59
+ @client = client
60
+ @attributes = ZendeskAPI::Trackie.new(attributes)
61
+
62
+ if self.class.associations.none? { |a| a[:name] == self.class.singular_resource_name }
63
+ ZendeskAPI::Client.check_deprecated_namespace_usage @attributes, self.class.singular_resource_name
64
+ end
65
+
66
+ @attributes.clear_changes unless new_record?
67
+ end
68
+
69
+ def self.new_from_response(client, response, includes = nil)
70
+ new(client).tap do |resource|
71
+ resource.handle_response(response)
72
+ resource.set_includes(resource, includes, response.body) if includes
73
+ resource.attributes.clear_changes
74
+ end
75
+ end
76
+
77
+ # Passes the method onto the attributes hash.
78
+ # If the attributes are nested (e.g. { :tickets => { :id => 1 } }), passes the method onto the nested hash.
79
+ def method_missing(*args, &block)
80
+ raise NoMethodError, ":save is not defined" if args.first.to_sym == :save
81
+ @attributes.send(*args, &block)
82
+ end
83
+
84
+ def respond_to_missing?(method, include_private = false)
85
+ @attributes.respond_to?(method) || super
86
+ end
87
+
88
+ # Returns the resource id of the object or nil
89
+ def id
90
+ key?(:id) ? method_missing(:id) : nil
91
+ end
92
+
93
+ # Has this been object been created server-side? Does this by checking for an id.
94
+ def new_record?
95
+ id.nil?
96
+ end
97
+
98
+ # @private
99
+ def loaded_associations
100
+ self.class.associations.select do |association|
101
+ loaded = @attributes.method_missing(association[:name])
102
+ loaded && !(loaded.respond_to?(:empty?) && loaded.empty?)
103
+ end
104
+ end
105
+
106
+ # Returns the path to the resource
107
+ def path(options = {})
108
+ @association.generate_path(self, options)
109
+ end
110
+
111
+ # Passes #to_json to the underlying attributes hash
112
+ def to_json(*args)
113
+ method_missing(:to_json, *args)
114
+ end
115
+
116
+ # @private
117
+ def to_s
118
+ "#{self.class.singular_resource_name}: #{attributes.inspect}"
119
+ end
120
+ alias :inspect :to_s
121
+
122
+ # Compares resources by class and id. If id is nil, then by object_id
123
+ def ==(other)
124
+ return true if other.object_id == object_id
125
+
126
+ if other && !(other.is_a?(Data) || other.is_a?(Integer))
127
+ warn "Trying to compare #{other.class} to a Resource from #{caller.first}"
128
+ end
129
+
130
+ if other.is_a?(Data)
131
+ other.id && other.id == id
132
+ elsif other.is_a?(Integer)
133
+ id == other
134
+ else
135
+ false
136
+ end
137
+ end
138
+ alias :eql :==
139
+
140
+ # @private
141
+ def inspect
142
+ "#<#{self.class.name} #{@attributes.to_hash.inspect}>"
143
+ end
144
+
145
+ alias :to_param :attributes
146
+
147
+ private
148
+
149
+ def attributes_for_save
150
+ { self.class.singular_resource_name.to_sym => attributes.changes }
151
+ end
152
+ end
153
+
154
+ # Indexable resource
155
+ class DataResource < Data
156
+ attr_accessor :error, :error_message
157
+ extend Verbs
158
+ end
159
+
160
+ # Represents a resource that can only GET
161
+ class ReadResource < DataResource
162
+ include Read
163
+ end
164
+
165
+ # Represents a resource that can only POST
166
+ class CreateResource < DataResource
167
+ include Create
168
+ end
169
+
170
+ # Represents a resource that can only PUT
171
+ class UpdateResource < DataResource
172
+ include Update
173
+ end
174
+
175
+ # Represents a resource that can only DELETE
176
+ class DeleteResource < DataResource
177
+ include Destroy
178
+ end
179
+
180
+ # Represents a resource that can CRUD (create, read, update, delete).
181
+ class Resource < DataResource
182
+ include Read
183
+ include Create
184
+
185
+ include Update
186
+ include Destroy
187
+ end
188
+
189
+ class SingularResource < Resource
190
+ def attributes_for_save
191
+ { self.class.resource_name.to_sym => attributes.changes }
192
+ end
193
+ end
194
+
195
+ # Namespace parent class for Data/Resource classes
196
+ module DataNamespace
197
+ class << self
198
+ def included(base)
199
+ @descendants ||= []
200
+ @descendants << base
201
+ end
202
+
203
+ def descendants
204
+ @descendants || []
205
+ end
206
+ end
207
+ end
208
+ end