From fd0e5289a6c9186fb47ca4dfb879a190452a431d Mon Sep 17 00:00:00 2001 From: Joshua Teitelbaum Date: Fri, 17 Oct 2025 14:55:24 -0700 Subject: [PATCH 1/3] adding new field mapping custom_field_symbol associative array, initialized from global meta data garnered from account context --- lib/zendesk_api/client.rb | 15 +++ lib/zendesk_api/configuration.rb | 4 + lib/zendesk_api/resources.rb | 54 ++++++++++ .../custom_field_symbol_proxy_spec.rb | 99 +++++++++++++++++++ spec/live/ticket_spec.rb | 57 +++++++++++ 5 files changed, 229 insertions(+) create mode 100644 spec/core/resources/custom_field_symbol_proxy_spec.rb diff --git a/lib/zendesk_api/client.rb b/lib/zendesk_api/client.rb index ec5fed22..cd84ad52 100644 --- a/lib/zendesk_api/client.rb +++ b/lib/zendesk_api/client.rb @@ -31,6 +31,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 @@ -102,6 +106,17 @@ 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 # Creates a connection if there is none, otherwise returns the existing connection. diff --git a/lib/zendesk_api/configuration.rb b/lib/zendesk_api/configuration.rb index 4f34df73..ba1e22e1 100644 --- a/lib/zendesk_api/configuration.rb +++ b/lib/zendesk_api/configuration.rb @@ -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 diff --git a/lib/zendesk_api/resources.rb b/lib/zendesk_api/resources.rb index 7df38c4e..27a232b4 100644 --- a/lib/zendesk_api/resources.rb +++ b/lib/zendesk_api/resources.rb @@ -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 diff --git a/spec/core/resources/custom_field_symbol_proxy_spec.rb b/spec/core/resources/custom_field_symbol_proxy_spec.rb new file mode 100644 index 00000000..69922e94 --- /dev/null +++ b/spec/core/resources/custom_field_symbol_proxy_spec.rb @@ -0,0 +1,99 @@ +require 'core/spec_helper' +require_relative '../../../lib/zendesk_api/resources' + +RSpec.describe ZendeskAPI::Ticket::CustomFieldSymbolProxy do + let(:field_metadata) do + [ + { id: 1, title: "foo" }, + { id: 2, title: "bar" } + ] + end + let(:client) do + double("Client").tap do |c| + allow(c).to receive(:ticket_fields_metadata).and_return(field_metadata) + end + end + let(:ticket) do + t = ZendeskAPI::Ticket.new({}) + t.instance_variable_set(:@client, client) + t.instance_variable_set(:@custom_fields, [{ id: 1, value: "abc" }]) + def t.custom_fields + _foo = 1 + @custom_fields + end + t + end + let(:proxy) { described_class.new(ticket, nil) } + + describe "[] and []=" do + it "reads a custom field by symbol (existing)" do + expect(proxy["foo"]).to eq("abc") + end + + it "returns nil for existing field with no value" do + ticket.instance_variable_set(:@custom_fields, [{ id: 1 }]) + expect(proxy["foo"]).to be_nil + end + + it "raises error for missing field title" do + expect { proxy["baz"] }.to raise_error(/Cannot find custom field/) + end + + it "writes a custom field by symbol (existing)" do + proxy["foo"] = "updated" + expect(ticket.custom_fields.find { |h| h[:id] == 1 }[:value]).to eq("updated") + end + + it "writes a custom field by symbol (new)" do + proxy["bar"] = "def" + expect(ticket.custom_fields.find { |h| h[:id] == 2 }[:value]).to eq("def") + end + end + + describe "delegation and integration" do + it "delegates to_a" do + expect(proxy.to_a).to eq(ticket.custom_fields) + end + + it "delegates method_missing and respond_to_missing?" do + expect(proxy.respond_to?(:each)).to be true + expect(proxy.map { |h| h[:id] }).to include(1) + end + + it "returns proxy from custom_field_symbol accessor" do + t = ZendeskAPI::Ticket.new({}) + t.instance_variable_set(:@client, client) + t.instance_variable_set(:@custom_fields, [{ id: 1, value: "abc" }]) + def t.custom_fields + _foo = 1 + @custom_fields + end + expect(t.custom_field_symbol["foo"]).to eq("abc") + end + + it "updates proxy on custom_field_symbol= assignment" do + t = ZendeskAPI::Ticket.new({}) + t.instance_variable_set(:@client, client) + def t.custom_fields + _foo = 1 + @custom_fields + end + t.custom_field_symbol = [{ id: 1, value: "xyz" }] + expect(t.custom_field_symbol.to_a).to eq([{ id: 1, value: "xyz" }]) + end + end + + describe "[] and []= with missing ticket_fields_metadata" do + before do + allow(client).to receive(:ticket_fields_metadata).and_return(nil) + end + + it "raises error for [] when ticket_fields_metadata is missing" do + expect { proxy["foo"] }.to raise_error(/configuration ticket_fields_metadata is OFF/) + end + + it "raises error for []= when ticket_fields_metadata is missing" do + expect { proxy["foo"] = "bar" }.to raise_error(/configuration ticket_fields_metadata is OFF/) + end + end +end diff --git a/spec/live/ticket_spec.rb b/spec/live/ticket_spec.rb index 72646b32..74c45ae2 100644 --- a/spec/live/ticket_spec.rb +++ b/spec/live/ticket_spec.rb @@ -211,4 +211,61 @@ def valid_attributes end end end + + describe "CustomFieldSymbolProxy" do + let(:field_metadata) do + [ + { id: 1, title: "foo" }, + { id: 2, title: "bar" } + ] + end + let(:client) do + double("Client", :instance_variable_get => field_metadata) + end + let(:ticket) do + t = ZendeskAPI::Ticket.allocate + t.instance_variable_set(:@client, client) + t.instance_variable_set(:@custom_fields, [{ id: 1, value: "abc" }]) + t + end + let(:proxy) { ZendeskAPI::Ticket::CustomFieldSymbolProxy.new(ticket, nil) } + + it "reads a custom field by symbol" do + expect(proxy["foo"]).to eq("abc") + end + + it "raises error for missing field" do + expect { proxy["baz"] }.to raise_error(/Cannot find custom field/) + end + + it "writes a custom field by symbol" do + proxy["bar"] = "def" + expect(ticket.custom_fields.find { |h| h[:id] == 2 }[:value]).to eq("def") + end + + it "delegates to_a" do + expect(proxy.to_a).to eq(ticket.custom_fields) + end + + it "delegates method_missing and respond_to_missing?" do + expect(proxy.respond_to?(:each)).to be true + expect(proxy.map { |h| h[:id] }).to eq([1]) + end + + describe "integration with Ticket methods" do + it "returns proxy from custom_field_symbol accessor" do + t = ZendeskAPI::Ticket.allocate + t.instance_variable_set(:@client, client) + t.instance_variable_set(:@custom_fields, [{ id: 1, value: "abc" }]) + expect(t.custom_field_symbol["foo"]).to eq("abc") + end + + it "updates proxy on custom_field_symbol= assignment" do + t = ZendeskAPI::Ticket.allocate + t.instance_variable_set(:@client, client) + t.custom_field_symbol = [{ id: 1, value: "xyz" }] + expect(t.custom_field_symbol.to_a).to eq([{ id: 1, value: "xyz" }]) + end + end + end end From b3042ce21ca9ed545bbd614f3ab1cd3a1260a2bd Mon Sep 17 00:00:00 2001 From: Joshua Teitelbaum Date: Sun, 12 Oct 2025 15:29:58 -0700 Subject: [PATCH 2/3] Adding notifications support for various lifecycle conditions --- Gemfile.lock | 1 + lib/zendesk_api/client.rb | 7 +- lib/zendesk_api/configuration.rb | 3 + .../middleware/request/etag_cache.rb | 12 +++ lib/zendesk_api/middleware/request/retry.rb | 16 ++++ .../response/zendesk_request_event.rb | 41 +++++++++ .../middleware/request/etag_cache_spec.rb | 59 +++++++++++++ spec/core/middleware/request/retry_spec.rb | 32 +++++++ .../response/zendesk_request_event_spec.rb | 86 +++++++++++++++++++ spec/spec_helper.rb | 7 ++ zendesk_api.gemspec | 1 + 11 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 lib/zendesk_api/middleware/response/zendesk_request_event.rb create mode 100644 spec/core/middleware/response/zendesk_request_event_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/Gemfile.lock b/Gemfile.lock index eccc83aa..b690d937 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: zendesk_api (3.1.1) + activesupport faraday (> 2.0.0) faraday-multipart hashie (>= 3.5.2) diff --git a/lib/zendesk_api/client.rb b/lib/zendesk_api/client.rb index cd84ad52..f0c5158b 100644 --- a/lib/zendesk_api/client.rb +++ b/lib/zendesk_api/client.rb @@ -11,6 +11,7 @@ require 'zendesk_api/middleware/request/encode_json' 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' @@ -161,6 +162,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 @@ -176,7 +178,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 @@ -188,7 +190,8 @@ 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 diff --git a/lib/zendesk_api/configuration.rb b/lib/zendesk_api/configuration.rb index ba1e22e1..e2fe16d8 100644 --- a/lib/zendesk_api/configuration.rb +++ b/lib/zendesk_api/configuration.rb @@ -58,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 diff --git a/lib/zendesk_api/middleware/request/etag_cache.rb b/lib/zendesk_api/middleware/request/etag_cache.rb index fef6c856..b816efb7 100644 --- a/lib/zendesk_api/middleware/request/etag_cache.rb +++ b/lib/zendesk_api/middleware/request/etag_cache.rb @@ -1,4 +1,5 @@ require "faraday/middleware" +require 'active_support/notifications' module ZendeskAPI module Middleware @@ -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) @@ -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 diff --git a/lib/zendesk_api/middleware/request/retry.rb b/lib/zendesk_api/middleware/request/retry.rb index 49932bcf..4d331ada 100644 --- a/lib/zendesk_api/middleware/request/retry.rb +++ b/lib/zendesk_api/middleware/request/retry.rb @@ -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 @@ -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 diff --git a/lib/zendesk_api/middleware/response/zendesk_request_event.rb b/lib/zendesk_api/middleware/response/zendesk_request_event.rb new file mode 100644 index 00000000..b8919f48 --- /dev/null +++ b/lib/zendesk_api/middleware/response/zendesk_request_event.rb @@ -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 diff --git a/spec/core/middleware/request/etag_cache_spec.rb b/spec/core/middleware/request/etag_cache_spec.rb index 9c2fdfa5..0399f2e3 100644 --- a/spec/core/middleware/request/etag_cache_spec.rb +++ b/spec/core/middleware/request/etag_cache_spec.rb @@ -1,4 +1,5 @@ require 'core/spec_helper' +require 'active_support/cache' describe ZendeskAPI::Middleware::Request::EtagCache do it "caches" do @@ -18,4 +19,62 @@ expect(response.headers[header]).to eq(first_response.headers[header]) end end + + context "instrumentation" do + let(:instrumentation) { double("Instrumentation") } + let(:cache) { ActiveSupport::Cache::MemoryStore.new } + let(:status) { nil } + let(:middleware) do + ZendeskAPI::Middleware::Request::EtagCache.new( + ->(env) { Faraday::Response.new(env) }, + cache: cache, + instrumentation: instrumentation + ) + end + let(:env) do + { + url: URI("https://example.zendesk.com/api/v2/blergh"), + method: :get, + request_headers: {}, + response_headers: { "Etag" => "x", x_rate_limit_remaining: 10 }, + status: status, + body: { "x" => 1 }, + response_body: { "x" => 1 } + } + end + let(:no_instrumentation_middleware) do + ZendeskAPI::Middleware::Request::EtagCache.new( + ->(env) { Faraday::Response.new(env) }, + cache: cache, + instrumentation: nil + ) + end + before do + allow(instrumentation).to receive(:instrument) + end + + it "emits cache_miss on first request" do + expect(instrumentation).to receive(:instrument).with( + "zendesk.cache_miss", + hash_including(endpoint: "/api/v2/blergh", status: 200) + ) + env[:status] = 200 + middleware.call(env).on_complete { |_e| 1 } + end + + it "don't care on no instrumentation" do + env[:status] = 200 + no_instrumentation_middleware.call(env).on_complete { |_e| 1 } + end + + it "emits cache_hit on 304 response" do + cache.write(middleware.cache_key(env), env) + expect(instrumentation).to receive(:instrument).with( + "zendesk.cache_hit", + hash_including(endpoint: "/api/v2/blergh", status: 304) + ) + env[:status] = 304 + middleware.call(env).on_complete { |_e| 1 } + end + end end diff --git a/spec/core/middleware/request/retry_spec.rb b/spec/core/middleware/request/retry_spec.rb index 82b58551..0460b951 100644 --- a/spec/core/middleware/request/retry_spec.rb +++ b/spec/core/middleware/request/retry_spec.rb @@ -118,4 +118,36 @@ def runtime end end end + + context "with instrumentation on retry" do + let(:instrumentation) { double("Instrumentation") } + let(:middleware) do + ZendeskAPI::Middleware::Request::Retry.new(client.connection.builder.app) + end + + before do + allow(instrumentation).to receive(:instrument) + client.config.instrumentation = instrumentation + # Inject instrumentation into middleware instance + allow_any_instance_of(ZendeskAPI::Middleware::Request::Retry).to receive(:instrumentation).and_return(instrumentation) + stub_request(:get, %r{instrumented}).to_return(:status => 429, :headers => { :retry_after => 1 }).to_return(:status => 200) + end + + it "calls instrumentation on retry" do + expect(instrumentation).to receive(:instrument).with( + "zendesk.retry", + hash_including(:attempt, :endpoint, :method, :reason, :delay) + ).at_least(:once) + client.connection.get("instrumented") + end + + it "does not call instrumentation when no retry occurs" do + stub_request(:get, %r{no_retry}).to_return(:status => 200) + expect(instrumentation).not_to receive(:instrument).with( + "zendesk.retry", + hash_including(:attempt, :endpoint, :method, :reason, :delay) + ) + client.connection.get("no_retry") + end + end end diff --git a/spec/core/middleware/response/zendesk_request_event_spec.rb b/spec/core/middleware/response/zendesk_request_event_spec.rb new file mode 100644 index 00000000..ee5bdb7f --- /dev/null +++ b/spec/core/middleware/response/zendesk_request_event_spec.rb @@ -0,0 +1,86 @@ +require_relative '../../../spec_helper' +require 'faraday' +require 'zendesk_api/middleware/response/zendesk_request_event' + +RSpec.describe ZendeskAPI::Middleware::Response::ZendeskRequestEvent do + let(:instrumentation) { double('Instrumentation') } + let(:client) do + double('Client', config: double('Config', instrumentation: instrumentation)) + end + let(:app) { ->(env) { Faraday::Response.new(env) } } + let(:middleware) { described_class.new(app, client) } + let(:response_headers) do + { + x_rate_limit_remaining: 10, + x_rate_limit: 100, + x_rate_limit_reset: 1234567890 + } + end + let(:env) do + { + url: URI('https://example.zendesk.com/api/v2/tickets'), + method: :get, + status: status, + response_headers: response_headers + } + end + + before do + allow(instrumentation).to receive(:instrument) + end + + context 'when the response status is less than 500' do + let(:status) { 200 } + + it 'instruments zendesk.request and zendesk.rate_limit' do + expect(instrumentation).to receive(:instrument).with( + 'zendesk.request', + hash_including(:duration, endpoint: '/api/v2/tickets', method: :get, status: 200) + ) + expect(instrumentation).to receive(:instrument).with( + 'zendesk.rate_limit', + hash_including(endpoint: '/api/v2/tickets', status: 200) + ) + middleware.call(env).on_complete { |_response_env| 1 } + end + end + + context 'when the response status is 500 or greater' do + let(:status) { 500 } + + it 'instruments only zendesk.request' do + expect(instrumentation).to receive(:instrument).with( + 'zendesk.request', + hash_including(:duration, endpoint: '/api/v2/tickets', method: :get, status: 500) + ) + expect(instrumentation).not_to receive(:instrument).with('zendesk.rate_limit', anything) + middleware.call(env).on_complete { |_response_env| 1 } + end + end + + context 'duration calculation' do + let(:status) { 201 } + + it 'passes a positive duration to instrumentation' do + expect(instrumentation).to receive(:instrument) do |event, payload| + if event == 'zendesk.request' + expect(payload[:duration]).to be > 0 + end + end + expect(instrumentation).to receive(:instrument).with('zendesk.rate_limit', anything) + middleware.call(env).on_complete { |_response_env| 1 } + end + end + + context 'when instrumentation is nil' do + let(:status) { 200 } + let(:client) do + double('Client', config: double('Config', instrumentation: nil)) + end + let(:middleware) { described_class.new(app, client) } + + it 'does not raise an error' do + expect { middleware.call(env).on_complete { |_response_env| 1 } }.not_to raise_error + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 00000000..186ba343 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,7 @@ +require 'rspec' + +RSpec.configure do |config| + config.expect_with :rspec do |c| + c.syntax = :expect + end +end diff --git a/zendesk_api.gemspec b/zendesk_api.gemspec index fec405c3..0b2aa620 100644 --- a/zendesk_api.gemspec +++ b/zendesk_api.gemspec @@ -33,4 +33,5 @@ Gem::Specification.new do |s| s.add_dependency "inflection" s.add_dependency "multipart-post", "~> 2.0" s.add_dependency "mini_mime" + s.add_dependency "activesupport" end From 2e7be06517cbbb501e886b133c3d79b903a0a31f Mon Sep 17 00:00:00 2001 From: Joshua Teitelbaum Date: Sat, 11 Oct 2025 20:50:07 -0700 Subject: [PATCH 3/3] pass in user id for just in time user impersonation override --- Gemfile.lock | 1 + lib/zendesk_api/client.rb | 21 +++++++ .../request/api_token_impersonate.rb | 28 +++++++++ spec/core/client_spec.rb | 36 +++++++++++ .../request/api_token_impersonate_spec.rb | 63 +++++++++++++++++++ spec/core/middleware/request/retry_spec.rb | 2 +- zendesk_api.gemspec | 1 + 7 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 lib/zendesk_api/middleware/request/api_token_impersonate.rb create mode 100644 spec/core/middleware/request/api_token_impersonate_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index b690d937..b8042c94 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: zendesk_api (3.1.1) activesupport + base64 faraday (> 2.0.0) faraday-multipart hashie (>= 3.5.2) diff --git a/lib/zendesk_api/client.rb b/lib/zendesk_api/client.rb index f0c5158b..4ade00da 100644 --- a/lib/zendesk_api/client.rb +++ b/lib/zendesk_api/client.rb @@ -9,6 +9,7 @@ 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' @@ -120,6 +121,25 @@ def load_ticket_fields_metadata @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. # # @return [Faraday::Connection] Faraday connection for the client @@ -198,6 +218,7 @@ def build_connection end builder.adapter(*adapter, &config.adapter_proc) + builder.use ZendeskAPI::Middleware::Request::ApiTokenImpersonate end end diff --git a/lib/zendesk_api/middleware/request/api_token_impersonate.rb b/lib/zendesk_api/middleware/request/api_token_impersonate.rb new file mode 100644 index 00000000..49f54e54 --- /dev/null +++ b/lib/zendesk_api/middleware/request/api_token_impersonate.rb @@ -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 diff --git a/spec/core/client_spec.rb b/spec/core/client_spec.rb index d19914a0..46cbbd38 100644 --- a/spec/core/client_spec.rb +++ b/spec/core/client_spec.rb @@ -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 diff --git a/spec/core/middleware/request/api_token_impersonate_spec.rb b/spec/core/middleware/request/api_token_impersonate_spec.rb new file mode 100644 index 00000000..39c43afe --- /dev/null +++ b/spec/core/middleware/request/api_token_impersonate_spec.rb @@ -0,0 +1,63 @@ +require 'core/spec_helper' + +RSpec.describe ZendeskAPI::Middleware::Request::ApiTokenImpersonate do + let(:app) { ->(env) { env } } + let(:middleware) { described_class.new(app) } + let(:username) { 'impersonated_user' } + let(:token) { 'abc123' } + let(:original_username) { 'original_user/token' } + let(:encoded_auth) { Base64.urlsafe_encode64("#{original_username}:#{token}") } + let(:env) do + { + request_headers: { + authorization: "Basic #{encoded_auth}" + } + } + end + + after { Thread.current[:zendesk_thread_local_username] = nil } + + context 'when local_username is set and authorization is a valid API token' do + it 'impersonates the user by modifying the Authorization header' do + Thread.current[:zendesk_thread_local_username] = username + result = middleware.call(env) + new_auth = result[:request_headers][:authorization] + decoded = Base64.urlsafe_decode64(new_auth.split.last) + expect(decoded).to eq("#{username}/token:#{token}") + end + end + + context 'when local_username is not set' do + it 'does not modify the Authorization header' do + result = middleware.call(env) + expect(result[:request_headers][:authorization]).to eq(env[:request_headers][:authorization]) + end + end + + context 'when authorization header is not Basic' do + it 'does not modify the Authorization header' do + Thread.current[:zendesk_thread_local_username] = username + env[:request_headers][:authorization] = 'Bearer something' + result = middleware.call(env) + expect(result[:request_headers][:authorization]).to eq('Bearer something') + end + end + + context 'when authorization does not contain /token:' do + it 'raises an error' do + Thread.current[:zendesk_thread_local_username] = username + env[:request_headers][:authorization] = "Basic #{Base64.urlsafe_encode64('user:abc123')}" + result = middleware.call(env) + expect(result[:request_headers][:authorization]).to eq("Basic #{Base64.urlsafe_encode64('user:abc123')}") + end + end + + context 'when authorization is not in valid format' do + it 'raises an error' do + Thread.current[:zendesk_thread_local_username] = username + env[:request_headers][:authorization] = "Basic #{Base64.urlsafe_encode64('user/token:abc123:extra')}" + result = middleware.call(env) + expect(result[:request_headers][:authorization]).to eq("Basic #{Base64.urlsafe_encode64('user/token:abc123:extra')}") + end + end +end diff --git a/spec/core/middleware/request/retry_spec.rb b/spec/core/middleware/request/retry_spec.rb index 0460b951..4abcd0e2 100644 --- a/spec/core/middleware/request/retry_spec.rb +++ b/spec/core/middleware/request/retry_spec.rb @@ -17,7 +17,7 @@ def runtime expect(client.connection.get("blergh").status).to eq(200) } - expect(seconds).to be_within(0.2).of(1) + expect(seconds).to be_within(0.3).of(1) end end diff --git a/zendesk_api.gemspec b/zendesk_api.gemspec index 0b2aa620..3f4188f0 100644 --- a/zendesk_api.gemspec +++ b/zendesk_api.gemspec @@ -34,4 +34,5 @@ Gem::Specification.new do |s| s.add_dependency "multipart-post", "~> 2.0" s.add_dependency "mini_mime" s.add_dependency "activesupport" + s.add_dependency "base64" end