Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ PATH
remote: .
specs:
zendesk_api (3.1.1)
activesupport
base64
faraday (> 2.0.0)
faraday-multipart
hashie (>= 3.5.2)
Expand Down
43 changes: 41 additions & 2 deletions lib/zendesk_api/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
require 'zendesk_api/middleware/request/raise_rate_limited'
require 'zendesk_api/middleware/request/upload'
require 'zendesk_api/middleware/request/encode_json'
require 'zendesk_api/middleware/request/api_token_impersonate'
require 'zendesk_api/middleware/request/url_based_access_token'
require 'zendesk_api/middleware/response/callback'
require 'zendesk_api/middleware/response/zendesk_request_event'
require 'zendesk_api/middleware/response/deflate'
require 'zendesk_api/middleware/response/gzip'
require 'zendesk_api/middleware/response/sanitize_response'
Expand All @@ -31,6 +33,10 @@ class Client
# @return [Array] Custom response callbacks
attr_reader :callbacks

def ticket_fields_metadata
@ticket_fields_metadata ||= []
end

# Handles resources such as 'tickets'. Any options are passed to the underlying collection, except reload which disregards
# memoization and creates a new Collection instance.
# @return [Collection] Collection instance for resource
Expand Down Expand Up @@ -102,6 +108,36 @@ def initialize
set_token_auth
set_default_logger
add_warning_callback
load_ticket_fields_metadata if @config.load_ticket_fields_metadata
end

def load_ticket_fields_metadata
@ticket_fields_metadata = []
ticket_fields.all do |f|
if f
@ticket_fields_metadata << f
end
end
@ticket_fields_metadata
end

# token impersonation for the scope of the block
# @param [String] username The username (email) of the user to impersonate
# @yield The block to run while impersonating the user
# @example
# client.api_token_impersonate("otheruser@yourcompany.com") do
# client.tickets.create(:subject => "Help!")
# end
#
# # creates a ticket on behalf of otheruser
# @return
# yielded value
def api_token_impersonate(username)
avant = Thread.current[:zendesk_thread_local_username]
Thread.current[:zendesk_thread_local_username] = username
yield
ensure
Thread.current[:zendesk_thread_local_username] = avant
end

# Creates a connection if there is none, otherwise returns the existing connection.
Expand Down Expand Up @@ -146,6 +182,7 @@ def build_connection
Faraday.new(config.options) do |builder|
# response
builder.use ZendeskAPI::Middleware::Response::RaiseError
builder.use ZendeskAPI::Middleware::Response::ZendeskRequestEvent, self if config.instrumentation.respond_to?(:instrument)
builder.use ZendeskAPI::Middleware::Response::Callback, self
builder.use ZendeskAPI::Middleware::Response::Logger, config.logger if config.logger
builder.use ZendeskAPI::Middleware::Response::ParseIsoDates
Expand All @@ -161,7 +198,7 @@ def build_connection
set_authentication(builder, config)

if config.cache
builder.use ZendeskAPI::Middleware::Request::EtagCache, :cache => config.cache
builder.use ZendeskAPI::Middleware::Request::EtagCache, { :cache => config.cache, :instrumentation => config.instrumentation }
end

builder.use ZendeskAPI::Middleware::Request::Upload
Expand All @@ -173,13 +210,15 @@ def build_connection
builder.use ZendeskAPI::Middleware::Request::Retry,
:logger => config.logger,
:retry_codes => config.retry_codes,
:retry_on_exception => config.retry_on_exception
:retry_on_exception => config.retry_on_exception,
:instrumentation => config.instrumentation
end
if config.raise_error_when_rate_limited
builder.use ZendeskAPI::Middleware::Request::RaiseRateLimited, :logger => config.logger
end

builder.adapter(*adapter, &config.adapter_proc)
builder.use ZendeskAPI::Middleware::Request::ApiTokenImpersonate
end
end

Expand Down
7 changes: 7 additions & 0 deletions lib/zendesk_api/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@ class Configuration
# @return [String] OAuth2 access_token
attr_accessor :access_token

# @return [String] url_based_access_token
attr_accessor :url_based_access_token

# @return [Boolean] load_ticket_fields_metadata
attr_accessor :load_ticket_fields_metadata

# Use this cache instead of default ZendeskAPI::LRUCache.new
# - must respond to read/write/fetch e.g. ActiveSupport::Cache::MemoryStore.new)
# - pass false to disable caching
Expand All @@ -54,6 +58,9 @@ class Configuration
# specify if you want a (network layer) exception to elicit a retry
attr_accessor :retry_on_exception

# specify if you wnat instrumentation to be used
attr_accessor :instrumentation

def initialize
@client_options = {}
@use_resource_cache = true
Expand Down
28 changes: 28 additions & 0 deletions lib/zendesk_api/middleware/request/api_token_impersonate.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
require 'base64'
module ZendeskAPI
# @private
module Middleware
# @private
module Request
# ApiTokenImpersonate
# If Thread.current[:zendesk_thread_local_username] is set, it will modify the Authorization header
# to impersonate that user using the API token from the current Authorization header.
class ApiTokenImpersonate < Faraday::Middleware
def call(env)
if Thread.current[:zendesk_thread_local_username] && env[:request_headers][:authorization] =~ /^Basic /
current_u_p_encoded = env[:request_headers][:authorization].split(/\s+/)[1]
current_u_p = Base64.urlsafe_decode64(current_u_p_encoded)
unless current_u_p.include?("/token:") && (parts = current_u_p.split(":")) && parts.length == 2 && parts[0].include?("/token")
warn "WARNING: ApiTokenImpersonate passed in invalid format. It should be in the format username/token:APITOKEN"
return @app.call(env)
end

next_u_p = "#{Thread.current[:zendesk_thread_local_username]}/token:#{parts[1]}"
env[:request_headers][:authorization] = "Basic #{Base64.urlsafe_encode64(next_u_p)}"
end
@app.call(env)
end
end
end
end
end
12 changes: 12 additions & 0 deletions lib/zendesk_api/middleware/request/etag_cache.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "faraday/middleware"
require 'active_support/notifications'

module ZendeskAPI
module Middleware
Expand All @@ -9,6 +10,7 @@ module Request
class EtagCache < Faraday::Middleware
def initialize(app, options = {})
@app = app
@instrumentation = options[:instrumentation] if options[:instrumentation].respond_to?(:instrument)
@cache = options[:cache] ||
raise("need :cache option e.g. ActiveSupport::Cache::MemoryStore.new")
@cache_key_prefix = options.fetch(:cache_key_prefix, :faraday_etags)
Expand Down Expand Up @@ -41,8 +43,18 @@ def call(environment)
:content_length => cached[:response_headers][:content_length],
:content_encoding => cached[:response_headers][:content_encoding]
)
@instrumentation&.instrument("zendesk.cache_hit",
{
endpoint: env[:url].path,
status: env[:status]
})
elsif env[:status] == 200 && env[:response_headers]["Etag"] # modified and cacheable
@cache.write(cache_key(env), env.to_hash)
@instrumentation&.instrument("zendesk.cache_miss",
{
endpoint: env[:url].path,
status: env[:status]
})
end
end
end
Expand Down
16 changes: 16 additions & 0 deletions lib/zendesk_api/middleware/request/retry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@ def initialize(app, options = {})
@logger = options[:logger]
@error_codes = options.key?(:retry_codes) && options[:retry_codes] ? options[:retry_codes] : DEFAULT_ERROR_CODES
@retry_on_exception = options.key?(:retry_on_exception) && options[:retry_on_exception] ? options[:retry_on_exception] : false
@instrumentation = options[:instrumentation]
end

def call(env)
original_env = env.dup
if original_env[:call_attempt]
original_env[:call_attempt] += 1
else
original_env[:call_attempt] = 1
end
exception_happened = false
if @retry_on_exception
begin
Expand All @@ -40,6 +46,16 @@ def call(env)

@logger.warn "You have been rate limited. Retrying in #{seconds_left} seconds..." if @logger

if @instrumentation
@instrumentation.instrument("zendesk.retry",
{
attempt: original_env[:call_attempt],
endpoint: original_env[:url].path,
method: original_env[:method],
reason: exception_happened ? 'exception' : 'rate_limited',
delay: seconds_left
})
end
seconds_left.times do |i|
sleep 1
time_left = seconds_left - i
Expand Down
41 changes: 41 additions & 0 deletions lib/zendesk_api/middleware/response/zendesk_request_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
require "faraday/response"

module ZendeskAPI
module Middleware
module Response
# @private
class ZendeskRequestEvent < Faraday::Middleware
def initialize(app, client)
super(app)
@client = client
end

def call(env)
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@app.call(env).on_complete do |response_env|
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
duration = (end_time - start_time) * 1000.0
instrumentation = @client.config.instrumentation
if instrumentation
instrumentation.instrument("zendesk.request",
{ duration: duration,
endpoint: response_env[:url].path,
method: response_env[:method],
status: response_env[:status] })
if response_env[:status] < 500
instrumentation.instrument("zendesk.rate_limit",
{
endpoint: response_env[:url].path,
status: response_env[:status],
threshold: response_env[:response_headers] ? response_env[:response_headers][:x_rate_limit_remaining] : nil,
limit: response_env[:response_headers] ? response_env[:response_headers][:x_rate_limit] : nil,
reset: response_env[:response_headers] ? response_env[:response_headers][:x_rate_limit_reset] : nil
})
end
end
end
end
end
end
end
end
54 changes: 54 additions & 0 deletions lib/zendesk_api/resources.rb
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,60 @@ class Ticket < Resource
extend UpdateMany
extend DestroyMany

# Proxy to trap array operator usage on custom_field_symbol
class CustomFieldSymbolProxy
def initialize(ticket, _arr)
@ticket = ticket
@field_array = @ticket.custom_fields || []
end

def [](key)
raise "Cannot find custom field #{key}, configuration ticket_fields_metadata is OFF" unless
@ticket.instance_variable_get("@client").ticket_fields_metadata
# Trap read access
fld = @ticket.instance_variable_get("@client").ticket_fields_metadata.find { |val| val[:title] == key }
raise "Cannot find custom field #{key}" unless fld
cf = @ticket.custom_fields.find { |h| h[:id] == fld[:id] }
cf ? cf[:value] : nil
end

def []=(key, value)
raise "Cannot find custom field #{key}, configuration ticket_fields_metadata is OFF" unless
@ticket.instance_variable_get("@client").ticket_fields_metadata
# Trap write access
fld = @ticket.instance_variable_get("@client").ticket_fields_metadata.find { |val| val[:title] == key }
raise "Cannot find custom field #{key}" unless fld
cf = @ticket.custom_fields.find { |h| h[:id] == fld[:id] } if @ticket.custom_fields
if cf
cf[:value] = value
else
@ticket.custom_fields << { id: fld[:id], value: value }
end
end

def to_a
@field_array
end

# Delegate other hash methods as needed
def method_missing(method, ...)
@field_array.send(method, ...)
end

def respond_to_missing?(method, include_private = false)
@field_array.respond_to?(method, include_private)
end
end

def custom_field_symbol
@custom_field_symbol ||= CustomFieldSymbolProxy.new(self, @custom_field_symbol)
end

def custom_field_symbol=(val)
@custom_field_symbol = val
@custom_field_symbol_proxy = CustomFieldSymbolProxy.new(self, @custom_field_symbol)
end

def self.cbp_path_regexes
[/^tickets$/, %r{organizations/\d+/tickets}, %r{users/\d+/tickets/requested}]
end
Expand Down
36 changes: 36 additions & 0 deletions spec/core/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -360,4 +360,40 @@ def url.to_str
expect(client.greeting_categories.path).to match(/channels\/voice\/greeting_categories/)
end
end

context "#api_token_impersonate" do
let(:impersonated_username) { "otheruser@yourcompany.com" }
let(:api_token) { "abc123" }
let(:client) do
ZendeskAPI::Client.new do |config|
config.url = "https://example.zendesk.com/api/v2"
config.username = "original@company.com"
config.token = api_token
config.adapter = :test
config.adapter_proc = proc do |stub|
stub.get "/api/v2/tickets" do |env|
[200, { 'content-type': "application/json", Authorization: env.request_headers["Authorization"] }, "null"]
end
end
end
end

it "impersonates the user for the scope of the block" do
result = nil
client.api_token_impersonate(impersonated_username) do
response = client.connection.get("/api/v2/tickets")
auth_header = response.env.request_headers["Authorization"]
decoded = Base64.urlsafe_decode64(auth_header.split.last)
expect(decoded).to start_with("#{impersonated_username}/token:")
result = response
end
expect(result).not_to be_nil
end

it "restores the previous username after the block" do
original = Thread.current[:zendesk_thread_local_username]
client.api_token_impersonate(impersonated_username) { 1 }
expect(Thread.current[:zendesk_thread_local_username]).to eq(original)
end
end
end
Loading
Loading