diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1251d6065..357ab505d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,6 +33,7 @@ jobs: - forest_admin_datasource_zendesk - forest_admin_datasource_snowflake - forest_admin_datasource_mambu_payments + - forest_admin_datasource_pylon steps: - name: Checkout @@ -76,6 +77,7 @@ jobs: - forest_admin_datasource_zendesk - forest_admin_datasource_snowflake - forest_admin_datasource_mambu_payments + - forest_admin_datasource_pylon services: mongodb: image: mongo:latest @@ -143,7 +145,7 @@ jobs: with: verbose: true oidc: true - files: ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_active_record/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_customizer/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_toolkit/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mongoid/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_rpc_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_rpc/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_zendesk/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_snowflake/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mambu_payments/coverage.json + files: ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_active_record/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_customizer/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_toolkit/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mongoid/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_rpc_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_rpc/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_zendesk/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_snowflake/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mambu_payments/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_pylon/coverage.json deploy: name: Release package diff --git a/.rubocop.yml b/.rubocop.yml index cfb997da3..22d638c14 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -41,6 +41,7 @@ Gemspec/RequireMFA: - 'packages/forest_admin_datasource_zendesk/forest_admin_datasource_zendesk.gemspec' - 'packages/forest_admin_datasource_snowflake/forest_admin_datasource_snowflake.gemspec' - 'packages/forest_admin_datasource_mambu_payments/forest_admin_datasource_mambu_payments.gemspec' + - 'packages/forest_admin_datasource_pylon/forest_admin_datasource_pylon.gemspec' # Offense count: 1 # This cop supports unsafe autocorrection (--autocorrect-all). @@ -131,6 +132,7 @@ Style/MutableConstant: - 'packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb' - 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/version.rb' - 'packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb' + - 'packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/version.rb' # Offense count: 38 # This cop supports safe autocorrection (--autocorrect). @@ -214,6 +216,7 @@ Style/StringLiterals: - 'packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb' - 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/version.rb' - 'packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb' + - 'packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/version.rb' # Offense count: 1 # This cop supports safe autocorrection (--autocorrect). @@ -287,6 +290,7 @@ Metrics/ModuleLength: - 'packages/forest_admin_datasource_customizer/spec/**/*' - 'packages/forest_admin_datasource_zendesk/spec/**/*' - 'packages/forest_admin_datasource_mambu_payments/spec/**/*' + - 'packages/forest_admin_datasource_pylon/spec/**/*' - 'packages/forest_admin_rails/spec/**/*' - 'packages/forest_admin_rpc_agent/spec/**/*' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb' diff --git a/packages/forest_admin_datasource_pylon/.gitignore b/packages/forest_admin_datasource_pylon/.gitignore new file mode 100644 index 000000000..06cfcfb83 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/.gitignore @@ -0,0 +1,8 @@ +*.gem +.bundle/ +Gemfile.lock +Gemfile-test.lock +coverage/ +pkg/ +tmp/ +.rspec_status diff --git a/packages/forest_admin_datasource_pylon/.rspec b/packages/forest_admin_datasource_pylon/.rspec new file mode 100644 index 000000000..34c5164d9 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/packages/forest_admin_datasource_pylon/Gemfile b/packages/forest_admin_datasource_pylon/Gemfile new file mode 100644 index 000000000..c229ff1d5 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/Gemfile @@ -0,0 +1,16 @@ +source 'https://rubygems.org' + +gemspec + +gem 'forest_admin_datasource_customizer' +gem 'forest_admin_datasource_toolkit' +gem 'rake', '~> 13.0' +gem 'rubocop', '1.86.1' +gem 'rubocop-performance', '1.26.1' +gem 'rubocop-rspec', '3.9.0' + +group :development, :test do + gem 'rspec', '~> 3.0' + gem 'simplecov', '~> 0.22', require: false + gem 'webmock', '~> 3.0' +end diff --git a/packages/forest_admin_datasource_pylon/Gemfile-test b/packages/forest_admin_datasource_pylon/Gemfile-test new file mode 100644 index 000000000..58b4383b1 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/Gemfile-test @@ -0,0 +1,19 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in forest_admin_datasource_pylon.gemspec +gemspec + +gem 'rake', '~> 13.0' +gem 'rubocop', '1.86.1' +gem 'rubocop-performance', '1.26.1' +gem 'rubocop-rspec', '3.9.0' + +group :development, :test do + gem 'forest_admin_datasource_customizer', path: '../forest_admin_datasource_customizer' + gem 'forest_admin_datasource_toolkit', path: '../forest_admin_datasource_toolkit' + gem 'rspec', '~> 3.0' + gem 'simplecov', '~> 0.22', require: false + gem 'simplecov-html', '~> 0.12.3' + gem 'simplecov_json_formatter', '~> 0.1.4' + gem 'webmock', '~> 3.0' +end diff --git a/packages/forest_admin_datasource_pylon/Rakefile b/packages/forest_admin_datasource_pylon/Rakefile new file mode 100644 index 000000000..4c774a2bf --- /dev/null +++ b/packages/forest_admin_datasource_pylon/Rakefile @@ -0,0 +1,6 @@ +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/packages/forest_admin_datasource_pylon/forest_admin_datasource_pylon.gemspec b/packages/forest_admin_datasource_pylon/forest_admin_datasource_pylon.gemspec new file mode 100644 index 000000000..e505fc905 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/forest_admin_datasource_pylon.gemspec @@ -0,0 +1,35 @@ +lib = File.expand_path('lib', __dir__) +$LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) + +require_relative 'lib/forest_admin_datasource_pylon/version' + +Gem::Specification.new do |spec| + spec.name = 'forest_admin_datasource_pylon' + spec.version = ForestAdminDatasourcePylon::VERSION + spec.authors = ['Forest Admin'] + spec.email = ['contact@forestadmin.com'] + spec.homepage = 'https://www.forestadmin.com' + spec.summary = 'Pylon datasource for Forest Admin Ruby agent.' + spec.description = 'Surface Pylon issues, accounts, contacts, users and teams as Forest Admin collections.' + spec.license = 'GPL-3.0' + spec.required_ruby_version = '>= 3.0.0' + + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = 'https://github.com/ForestAdmin/agent-ruby' + spec.metadata['changelog_uri'] = 'https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md' + spec.metadata['rubygems_mfa_required'] = 'false' + + spec.files = Dir.chdir(__dir__) do + `git ls-files -z`.split("\x0").reject do |f| + (File.expand_path(f) == __FILE__) || + f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile]) + end + end + spec.bindir = 'exe' + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ['lib'] + + spec.add_dependency 'faraday', '~> 2.0' + spec.add_dependency 'faraday-retry', '~> 2.0' + spec.add_dependency 'zeitwerk', '~> 2.3' +end diff --git a/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon.rb b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon.rb new file mode 100644 index 000000000..16adf3521 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon.rb @@ -0,0 +1,45 @@ +require_relative 'forest_admin_datasource_pylon/version' +require 'json' +require 'logger' +require 'zeitwerk' +require 'faraday' +require 'faraday/retry' +require 'forest_admin_datasource_toolkit' + +loader = Zeitwerk::Loader.for_gem +loader.setup + +module ForestAdminDatasourcePylon + class Error < StandardError; end + class ConfigurationError < Error; end + class UnsupportedOperatorError < Error; end + + # Raised when a Pylon API call fails. Carries the HTTP status and the + # (parsed) response body so callers — smart actions in particular — can + # surface Pylon's own validation message instead of a generic string. + class APIError < Error + attr_reader :status, :body + + def initialize(message, status: nil, body: nil) + super(message) + @status = status + @body = body + end + end + + class << self + attr_writer :logger + + def logger + @logger ||= default_logger + end + + private + + def default_logger + return Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger + + Logger.new($stderr).tap { |l| l.progname = 'forest_admin_datasource_pylon' } + end + end +end diff --git a/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/client.rb b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/client.rb new file mode 100644 index 000000000..18121a53e --- /dev/null +++ b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/client.rb @@ -0,0 +1,97 @@ +module ForestAdminDatasourcePylon + class Client + def initialize(configuration) + @configuration = configuration + end + + # Health check: Pylon returns the details of the organization owning the + # token, which is enough to prove the credentials are usable. + def me + must_succeed('me') { extract_data(connection.get('me').body) } + end + + private + + # Pylon wraps payloads in { "data": ..., "pagination": ..., "request_id": ... }. + def extract_data(body) + return nil if body.nil? || body == '' + return body['data'] if body.is_a?(Hash) && body.key?('data') + + body + end + + def must_succeed(operation) + yield + rescue Faraday::Error => e + raise api_error(operation, e) + rescue APIError + # Already mapped, with its status intact; re-wrapping would erase it. + raise + rescue StandardError => e + raise APIError, "Pylon API call failed: #{operation}: #{e.class}: #{e.message}" + end + + # Builds an APIError preserving the HTTP status and Pylon's own error body so + # smart actions can show the operator the real reason instead of "failed". + def api_error(operation, error) + response = error.respond_to?(:response) ? error.response : nil + status = response.is_a?(Hash) ? response[:status] : nil + body = parse_body(response.is_a?(Hash) ? response[:body] : nil) + detail = error_detail(status, body) || "#{error.class}: #{error.message}" + APIError.new("Pylon API call failed: #{operation}: #{detail}", status: status, body: body) + end + + def error_detail(status, body) + return nil unless status + + "HTTP #{status} #{error_message(body)}".strip + end + + def error_message(parsed) + return parsed.to_s[0, 500] unless parsed.is_a?(Hash) + + nested = parsed['error'] + message = parsed['message'] || (nested.is_a?(Hash) ? nested['message'] : nested) || + join_errors(parsed['errors']) + message = parsed.to_json if message.to_s.empty? + # Truncate before appending: the request_id is what support needs, so it + # must not be the first thing a long error body pushes out. + append_request_id(message.to_s[0, 500], parsed['request_id']) + end + + def append_request_id(message, request_id) + return message unless request_id + + "#{message} (request_id: #{request_id})" + end + + def join_errors(errors) + Array(errors).filter_map { |e| e.is_a?(Hash) ? (e['message'] || e['detail']) : e }.join('; ') + end + + def parse_body(body) + return body unless body.is_a?(String) && !body.empty? + + JSON.parse(body) + rescue JSON::ParserError + body + end + + # Middleware order is deliberate: `raise_error` sits outside the JSON parser + # so it raises with an already-parsed body, and `retry` sits innermost so it + # inspects raw statuses — behind `raise_error` it would never see a 429. + def connection + @connection ||= Faraday.new(url: @configuration.url) do |f| + f.request :json + f.response :raise_error + f.response :json + f.request :retry, **@configuration.retry_policy.to_faraday_options + f.headers['Authorization'] = "Bearer #{@configuration.api_key}" + f.headers['Accept'] = 'application/json' + f.headers['User-Agent'] = "forest_admin_datasource_pylon/#{VERSION}" + f.options.open_timeout = @configuration.open_timeout + f.options.timeout = @configuration.timeout + end + end + end +end diff --git a/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/configuration.rb b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/configuration.rb new file mode 100644 index 000000000..e7b17dc56 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/configuration.rb @@ -0,0 +1,36 @@ +module ForestAdminDatasourcePylon + class Configuration + DEFAULT_BASE_URL = 'https://api.usepylon.com'.freeze + + attr_reader :api_key, :base_url, :open_timeout, :timeout, :retry_policy + + def initialize(api_key:, base_url: nil, open_timeout: 5, timeout: 30, retry_policy: RetryPolicy.new) + @api_key = api_key + @base_url = base_url || DEFAULT_BASE_URL + @open_timeout = open_timeout + @timeout = timeout + @retry_policy = retry_policy + validate! + end + + # Pylon exposes unversioned paths (`/issues`, `/me`) directly under the host. + def url + @base_url.chomp('/') + end + + private + + def validate! + missing = [] + missing << 'api_key' if blank?(@api_key) + return if missing.empty? + + raise ConfigurationError, + "ForestAdminDatasourcePylon missing required config: #{missing.join(", ")}" + end + + def blank?(value) + value.nil? || value.to_s.strip.empty? + end + end +end diff --git a/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/retry_policy.rb b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/retry_policy.rb new file mode 100644 index 000000000..5aaa3a1ae --- /dev/null +++ b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/retry_policy.rb @@ -0,0 +1,53 @@ +module ForestAdminDatasourcePylon + # Everything governing how the client reacts to a failed request, in one + # place: which statuses and exceptions are worth another attempt, on which + # verbs, and how long to wait. + class RetryPolicy + # Pylon quotas are per-minute, so a 429 routinely carries a Retry-After of up + # to 60s. faraday-retry gives up outright when Retry-After exceeds + # max_interval (`return if retry_after > max_interval`), so the cap has to + # cover a full rate-limit window or the 429 retry never fires when it matters. + DEFAULT_MAX_INTERVAL = 65 + + STATUSES = [429, 502, 503, 504].freeze + + # faraday-retry's defaults plus ConnectionFailed: a dropped connection is + # exactly the transient failure a resilient client should absorb, and it is + # not retried out of the box. + EXCEPTIONS = [ + Errno::ETIMEDOUT, 'Timeout::Error', Faraday::TimeoutError, + Faraday::RetriableResponse, Faraday::ConnectionFailed + ].freeze + + # Faraday only retries these by default; a 429 is safe to retry on any verb + # because Pylon rejected the request before processing it, whereas a 502 on a + # POST /issues may well have created the issue. This has to go through + # retry_if rather than methods: faraday-retry ORs the two, so methods can + # only widen the set, never restrict it. + IDEMPOTENT_METHODS = %i[delete get head options put].freeze + RETRY_IF = ->(env, _exception) { env[:status] == 429 } + + BACKOFF_FACTOR = 2 + + attr_reader :max_retries, :interval, :max_interval + + def initialize(max_retries: 3, interval: 0.5, max_interval: DEFAULT_MAX_INTERVAL) + @max_retries = max_retries + @interval = interval + @max_interval = max_interval + end + + def to_faraday_options + { + max: @max_retries, + interval: @interval, + max_interval: @max_interval, + backoff_factor: BACKOFF_FACTOR, + retry_statuses: STATUSES, + exceptions: EXCEPTIONS, + methods: IDEMPOTENT_METHODS, + retry_if: RETRY_IF + } + end + end +end diff --git a/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/version.rb b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/version.rb new file mode 100644 index 000000000..006bc4e13 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/version.rb @@ -0,0 +1,3 @@ +module ForestAdminDatasourcePylon + VERSION = "1.36.2" +end diff --git a/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/client_spec.rb b/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/client_spec.rb new file mode 100644 index 000000000..28a5ea9ee --- /dev/null +++ b/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/client_spec.rb @@ -0,0 +1,201 @@ +RSpec.describe ForestAdminDatasourcePylon::Client do + let(:retry_policy) { ForestAdminDatasourcePylon::RetryPolicy.new(max_retries: 2, interval: 0) } + let(:configuration) { ForestAdminDatasourcePylon::Configuration.new(api_key: 'k', retry_policy: retry_policy) } + let(:client) { described_class.new(configuration) } + let(:base) { configuration.url } + + def json(payload, status = 200) + { status: status, body: payload.is_a?(String) ? payload : payload.to_json, + headers: { 'Content-Type' => 'application/json' } } + end + + describe 'authentication' do + it 'sends the api key as a Bearer token' do + stub_request(:get, "#{base}/me").to_return(json('data' => {})) + + client.me + expect(WebMock).to have_requested(:get, "#{base}/me") + .with(headers: { 'Authorization' => 'Bearer k', 'Accept' => 'application/json' }) + end + + it 'advertises a versioned user agent' do + stub_request(:get, "#{base}/me").to_return(json('data' => {})) + + client.me + expect(WebMock).to have_requested(:get, "#{base}/me") + .with(headers: { 'User-Agent' => "forest_admin_datasource_pylon/#{ForestAdminDatasourcePylon::VERSION}" }) + end + end + + describe '#me' do + it 'unwraps the "data" envelope' do + stub_request(:get, "#{base}/me").to_return(json('data' => { 'id' => 'org_1', 'name' => 'Acme' }, + 'request_id' => 'req_1')) + + expect(client.me).to eq('id' => 'org_1', 'name' => 'Acme') + end + + it 'returns the body as-is when it is not wrapped' do + stub_request(:get, "#{base}/me").to_return(json('id' => 'org_1')) + + expect(client.me).to eq('id' => 'org_1') + end + + it 'returns nil when the response has an empty body' do + stub_request(:get, "#{base}/me").to_return(status: 200, body: '') + + expect(client.me).to be_nil + end + + it 'wraps an unauthorized response in an APIError carrying status and body' do + stub_request(:get, "#{base}/me").to_return(json({ 'message' => 'invalid token' }, 401)) + + expect { client.me }.to raise_error(ForestAdminDatasourcePylon::APIError) { |error| + expect(error.message).to eq('Pylon API call failed: me: HTTP 401 invalid token') + expect(error.status).to eq(401) + expect(error.body).to eq('message' => 'invalid token') + } + end + + it 'appends the request_id when Pylon returns one' do + stub_request(:get, "#{base}/me").to_return(json({ 'message' => 'boom', 'request_id' => 'req_42' }, 500)) + + expect { client.me } + .to raise_error(ForestAdminDatasourcePylon::APIError, /boom \(request_id: req_42\)/) + end + + it 'keeps the request_id even when the message is truncated' do + body = { 'message' => 'x' * 900, 'request_id' => 'req_42' } + stub_request(:get, "#{base}/me").to_return(json(body, 500)) + + expect { client.me }.to raise_error(ForestAdminDatasourcePylon::APIError, /\(request_id: req_42\)\z/) + end + + it 'reads the message out of a nested error object' do + stub_request(:get, "#{base}/me").to_return(json({ 'error' => { 'message' => 'nested boom' } }, 422)) + + expect { client.me }.to raise_error(ForestAdminDatasourcePylon::APIError, /HTTP 422 nested boom/) + end + + it 'reads the message out of a plain string error' do + stub_request(:get, "#{base}/me").to_return(json({ 'error' => 'flat boom' }, 422)) + + expect { client.me }.to raise_error(ForestAdminDatasourcePylon::APIError, /HTTP 422 flat boom/) + end + + it 'joins an errors array, accepting hashes and bare strings' do + body = { 'errors' => [{ 'message' => 'first' }, { 'detail' => 'second' }, 'third'] } + stub_request(:get, "#{base}/me").to_return(json(body, 422)) + + expect { client.me }.to raise_error(ForestAdminDatasourcePylon::APIError, /first; second; third/) + end + + it 'falls back to the raw body when it is not JSON' do + stub_request(:get, "#{base}/me").to_return(status: 500, body: 'boom') + + expect { client.me }.to raise_error(ForestAdminDatasourcePylon::APIError, /HTTP 500 boom/) + end + + it 'falls back to the serialized payload when no message field is recognised' do + stub_request(:get, "#{base}/me").to_return(json({ 'unexpected' => true }, 400)) + + expect { client.me }.to raise_error(ForestAdminDatasourcePylon::APIError, /HTTP 400 .*unexpected/) + end + + it 'reports a connection failure without an HTTP status' do + stub_request(:get, "#{base}/me").to_timeout + + expect { client.me }.to raise_error(ForestAdminDatasourcePylon::APIError) { |error| + expect(error.status).to be_nil + expect(error.message).to match(/Pylon API call failed: me: Faraday::ConnectionFailed/) + } + end + + it 'wraps a non-Faraday failure in an APIError' do + allow(client).to receive(:extract_data).and_raise(KeyError, 'nope') + stub_request(:get, "#{base}/me").to_return(json('data' => {})) + + expect { client.me }.to raise_error(ForestAdminDatasourcePylon::APIError, /me: KeyError: nope/) + end + + # Callers branch on `status`, so an already-mapped error has to come back + # untouched rather than be re-wrapped by the generic StandardError arm. + it 'lets an already-mapped APIError through with its status intact' do + mapped = ForestAdminDatasourcePylon::APIError.new('boom', status: 404, body: { 'message' => 'boom' }) + allow(client).to receive(:extract_data).and_raise(mapped) + stub_request(:get, "#{base}/me").to_return(json('data' => {})) + + expect { client.me }.to raise_error(ForestAdminDatasourcePylon::APIError) { |error| + expect(error).to be(mapped) + expect(error.status).to eq(404) + } + end + end + + describe 'rate limiting' do + it 'retries a 429 and returns the eventual success' do + stub_request(:get, "#{base}/me") + .to_return(json({ 'message' => 'slow down' }, 429)) + .then.to_return(json('data' => { 'id' => 'org_1' })) + + expect(client.me).to eq('id' => 'org_1') + expect(WebMock).to have_requested(:get, "#{base}/me").twice + end + + it 'retries a 503 and returns the eventual success' do + stub_request(:get, "#{base}/me") + .to_return(json({ 'message' => 'unavailable' }, 503)) + .then.to_return(json('data' => { 'id' => 'org_1' })) + + expect(client.me).to eq('id' => 'org_1') + expect(WebMock).to have_requested(:get, "#{base}/me").twice + end + + it 'gives up after the configured retry budget and raises the last error' do + stub_request(:get, "#{base}/me").to_return(json({ 'message' => 'slow down' }, 429)) + + expect { client.me }.to raise_error(ForestAdminDatasourcePylon::APIError) { |error| + expect(error.status).to eq(429) + } + expect(WebMock).to have_requested(:get, "#{base}/me").times(3) + end + + it 'does not retry a 404' do + stub_request(:get, "#{base}/me").to_return(json({ 'message' => 'nope' }, 404)) + + expect { client.me }.to raise_error(ForestAdminDatasourcePylon::APIError) + expect(WebMock).to have_requested(:get, "#{base}/me").once + end + + it 'retries a dropped connection' do + stub_request(:get, "#{base}/me").to_timeout.then.to_return(json('data' => { 'id' => 'org_1' })) + + expect(client.me).to eq('id' => 'org_1') + expect(WebMock).to have_requested(:get, "#{base}/me").twice + end + + # Regression: max_interval used to be hardcoded to 5s. faraday-retry gives up + # outright when Retry-After exceeds max_interval, so a Pylon 429 carrying a + # per-minute Retry-After silently performed zero retries. + describe 'Retry-After' do + it 'honours a Retry-After that fits within the cap' do + stub_request(:get, "#{base}/me") + .to_return(status: 429, headers: { 'Retry-After' => '0' }) + .then.to_return(json('data' => { 'id' => 'org_1' })) + + expect(client.me).to eq('id' => 'org_1') + expect(WebMock).to have_requested(:get, "#{base}/me").twice + end + + it 'gives up without retrying when Retry-After exceeds the cap' do + impatient = ForestAdminDatasourcePylon::Configuration.new( + api_key: 'k', retry_policy: ForestAdminDatasourcePylon::RetryPolicy.new(interval: 0, max_interval: 1) + ) + stub_request(:get, "#{base}/me").to_return(status: 429, headers: { 'Retry-After' => '5' }) + + expect { described_class.new(impatient).me }.to raise_error(ForestAdminDatasourcePylon::APIError) + expect(WebMock).to have_requested(:get, "#{base}/me").once + end + end + end +end diff --git a/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/configuration_spec.rb b/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/configuration_spec.rb new file mode 100644 index 000000000..bb26125e3 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/configuration_spec.rb @@ -0,0 +1,59 @@ +RSpec.describe ForestAdminDatasourcePylon::Configuration do + let(:valid_args) { { api_key: 'pk_test_xyz' } } + + describe '#initialize' do + it 'accepts a valid api_key' do + expect(described_class.new(**valid_args).api_key).to eq('pk_test_xyz') + end + + it 'raises a ConfigurationError when api_key is nil' do + expect { described_class.new(api_key: nil) } + .to raise_error(ForestAdminDatasourcePylon::ConfigurationError, /api_key/) + end + + it 'raises a ConfigurationError when api_key is blank' do + expect { described_class.new(api_key: ' ') } + .to raise_error(ForestAdminDatasourcePylon::ConfigurationError, /api_key/) + end + + it 'defaults to the public Pylon base URL' do + expect(described_class.new(**valid_args).base_url).to eq('https://api.usepylon.com') + end + + it 'honours an explicit base_url override' do + config = described_class.new(**valid_args, base_url: 'https://example.test') + expect(config.base_url).to eq('https://example.test') + end + + it 'defaults the timeouts' do + config = described_class.new(**valid_args) + expect([config.open_timeout, config.timeout]).to eq([5, 30]) + end + + it 'keeps configurable timeouts' do + config = described_class.new(**valid_args, open_timeout: 1, timeout: 2) + expect([config.open_timeout, config.timeout]).to eq([1, 2]) + end + + it 'defaults to a standard retry policy' do + expect(described_class.new(**valid_args).retry_policy) + .to be_a(ForestAdminDatasourcePylon::RetryPolicy) + end + + it 'accepts an injected retry policy' do + policy = ForestAdminDatasourcePylon::RetryPolicy.new(max_retries: 9) + expect(described_class.new(**valid_args, retry_policy: policy).retry_policy).to be(policy) + end + end + + describe '#url' do + it 'returns the base URL unversioned' do + expect(described_class.new(**valid_args).url).to eq('https://api.usepylon.com') + end + + it 'trims a trailing slash' do + config = described_class.new(**valid_args, base_url: 'https://example.test/') + expect(config.url).to eq('https://example.test') + end + end +end diff --git a/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/retry_policy_spec.rb b/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/retry_policy_spec.rb new file mode 100644 index 000000000..f88450901 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/retry_policy_spec.rb @@ -0,0 +1,55 @@ +RSpec.describe ForestAdminDatasourcePylon::RetryPolicy do + describe '#initialize' do + it 'defaults the retry budget' do + policy = described_class.new + expect([policy.max_retries, policy.interval, policy.max_interval]) + .to eq([3, 0.5, described_class::DEFAULT_MAX_INTERVAL]) + end + + it 'keeps overridden values' do + policy = described_class.new(max_retries: 5, interval: 0.1, max_interval: 2) + expect([policy.max_retries, policy.interval, policy.max_interval]).to eq([5, 0.1, 2]) + end + end + + describe 'DEFAULT_MAX_INTERVAL' do + # faraday-retry gives up outright when Retry-After exceeds max_interval, and + # Pylon quotas are per-minute, so a cap below 60s silently disables the 429 + # retry on exactly the throttled endpoints it exists for. + it 'covers a full Pylon rate-limit window' do + expect(described_class::DEFAULT_MAX_INTERVAL).to be >= 60 + end + end + + describe '#to_faraday_options' do + subject(:options) { described_class.new(max_retries: 2, interval: 0.1, max_interval: 7).to_faraday_options } + + it 'maps the budget onto faraday-retry keys' do + expect(options).to include(max: 2, interval: 0.1, max_interval: 7, backoff_factor: 2) + end + + it 'retries the throttling and transient-gateway statuses' do + expect(options[:retry_statuses]).to eq([429, 502, 503, 504]) + end + + it 'retries dropped connections on top of faraday-retry defaults' do + expect(options[:exceptions]).to include(Faraday::ConnectionFailed, Faraday::RetriableResponse) + end + + it 'limits blanket retries to idempotent verbs' do + expect(options[:methods]).to eq(%i[delete get head options put]) + expect(options[:methods]).not_to include(:post, :patch) + end + end + + describe 'RETRY_IF' do + it 'allows retrying a non-idempotent verb when Pylon answered 429' do + expect(described_class::RETRY_IF.call({ status: 429 }, nil)).to be(true) + end + + it 'refuses to retry a non-idempotent verb on any other failure' do + expect(described_class::RETRY_IF.call({ status: 502 }, nil)).to be(false) + expect(described_class::RETRY_IF.call({ status: nil }, nil)).to be(false) + end + end +end diff --git a/packages/forest_admin_datasource_pylon/spec/spec_helper.rb b/packages/forest_admin_datasource_pylon/spec/spec_helper.rb new file mode 100644 index 000000000..68c4001aa --- /dev/null +++ b/packages/forest_admin_datasource_pylon/spec/spec_helper.rb @@ -0,0 +1,40 @@ +require 'simplecov' +# JSON output is consumed by the qlty CI coverage step; HTML is for local +# inspection. simplecov-html and simplecov_json_formatter are required only +# in Gemfile-test, so guard the require for local Gemfile runs. +begin + require 'simplecov_json_formatter' + require 'simplecov-html' + SimpleCov.formatters = [SimpleCov::Formatter::JSONFormatter, SimpleCov::Formatter::HTMLFormatter] +rescue LoadError + # Local Gemfile run without the CI formatters; default text output is fine. +end + +SimpleCov.start do + add_filter '/spec/' + enable_coverage :branch + minimum_coverage 90 +end + +SimpleCov.coverage_dir 'coverage' + +require 'webmock/rspec' +require 'forest_admin_datasource_customizer' +require 'forest_admin_datasource_pylon' + +WebMock.disable_net_connect!(allow_localhost: true) + +RSpec.configure do |config| + config.expect_with :rspec do |c| + c.syntax = :expect + end + config.mock_with :rspec do |m| + m.verify_partial_doubles = true + end + config.disable_monkey_patching! + config.warnings = false + config.order = :random + Kernel.srand config.seed + + config.before { WebMock.reset! } +end