From 8d94a91c645aef3f58be62dd61bf8be3ea02d9ce Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 4 Aug 2026 16:20:46 +0200 Subject: [PATCH] feat(pylon): scaffold datasource gem with config and resilient client Story 1 of the Pylon datasource (EXT-5). Adds the forest_admin_datasource_pylon gem skeleton: Zeitwerk autoloading, typed error hierarchy with an APIError carrying HTTP status and parsed body, configurable logger, Configuration with api_key validation, and a Faraday client authenticating with a Bearer token plus a GET /me health check. The Faraday middleware order is deliberate and differs from the Mambu Payments gem: raise_error sits outside the JSON parser so errors carry an already-parsed body, and retry sits innermost so it can observe raw statuses. Behind raise_error the retry middleware never sees a 429 and retry_statuses silently does nothing. Non-idempotent verbs are only retried on 429, where Pylon rejected the request before processing it. Wires the package into the CI lint, test and coverage jobs. The semantic-release publish pipeline is intentionally left untouched until Story 9, so an incomplete gem is never pushed to RubyGems. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yml | 4 +- .rubocop.yml | 5 + .../forest_admin_datasource_pylon/.gitignore | 8 + packages/forest_admin_datasource_pylon/.rspec | 3 + .../forest_admin_datasource_pylon/Gemfile | 16 ++ .../Gemfile-test | 19 ++ .../forest_admin_datasource_pylon/Rakefile | 6 + .../forest_admin_datasource_pylon.gemspec | 36 ++++ .../lib/forest_admin_datasource_pylon.rb | 44 +++++ .../forest_admin_datasource_pylon/client.rb | 97 +++++++++++ .../configuration.rb | 37 ++++ .../forest_admin_datasource_pylon/version.rb | 3 + .../client_spec.rb | 162 ++++++++++++++++++ .../configuration_spec.rb | 51 ++++++ .../spec/spec_helper.rb | 40 +++++ 15 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 packages/forest_admin_datasource_pylon/.gitignore create mode 100644 packages/forest_admin_datasource_pylon/.rspec create mode 100644 packages/forest_admin_datasource_pylon/Gemfile create mode 100644 packages/forest_admin_datasource_pylon/Gemfile-test create mode 100644 packages/forest_admin_datasource_pylon/Rakefile create mode 100644 packages/forest_admin_datasource_pylon/forest_admin_datasource_pylon.gemspec create mode 100644 packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon.rb create mode 100644 packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/client.rb create mode 100644 packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/configuration.rb create mode 100644 packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/version.rb create mode 100644 packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/client_spec.rb create mode 100644 packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/configuration_spec.rb create mode 100644 packages/forest_admin_datasource_pylon/spec/spec_helper.rb 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..0047a84d9 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). @@ -256,6 +259,7 @@ Metrics/ParameterLists: Exclude: - 'packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/base_collection.rb' - 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/datasource.rb' + - 'packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/configuration.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/routes/query_handler.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/services/smart_action_checker.rb' - 'packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/related/list_related_spec.rb' @@ -287,6 +291,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..ffe0ac579 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/forest_admin_datasource_pylon.gemspec @@ -0,0 +1,36 @@ +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 'activesupport', '>= 6.1' + 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..1f4894c35 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon.rb @@ -0,0 +1,44 @@ +require_relative 'forest_admin_datasource_pylon/version' +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..7eed5dbf4 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/client.rb @@ -0,0 +1,97 @@ +module ForestAdminDatasourcePylon + class Client + RETRY_STATUSES = [429, 502, 503, 504].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. + IDEMPOTENT_METHODS = %i[delete get head options put].freeze + RETRY_IF = ->(env, _exception) { env[:status] == 429 } + + 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 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)].compact.join(' ').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? + message = "#{message} (request_id: #{parsed["request_id"]})" if parsed['request_id'] + message.to_s[0, 500] + 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, max: @configuration.max_retries, interval: @configuration.retry_interval, + backoff_factor: 2, max_interval: 5, retry_statuses: RETRY_STATUSES, + methods: IDEMPOTENT_METHODS, retry_if: RETRY_IF + 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..6c2d138d8 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/configuration.rb @@ -0,0 +1,37 @@ +module ForestAdminDatasourcePylon + class Configuration + DEFAULT_BASE_URL = 'https://api.usepylon.com'.freeze + + attr_reader :api_key, :base_url, :open_timeout, :timeout, :max_retries, :retry_interval + + def initialize(api_key:, base_url: nil, open_timeout: 5, timeout: 30, max_retries: 3, retry_interval: 0.5) + @api_key = api_key + @base_url = base_url || DEFAULT_BASE_URL + @open_timeout = open_timeout + @timeout = timeout + @max_retries = max_retries + @retry_interval = retry_interval + 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/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..69f060558 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/client_spec.rb @@ -0,0 +1,162 @@ +RSpec.describe ForestAdminDatasourcePylon::Client do + let(:configuration) do + ForestAdminDatasourcePylon::Configuration.new(api_key: 'k', max_retries: 2, retry_interval: 0) + end + 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 '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 + 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 + + 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 +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..1e8277658 --- /dev/null +++ b/packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/configuration_spec.rb @@ -0,0 +1,51 @@ +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 and retry budget' do + config = described_class.new(**valid_args) + expect([config.open_timeout, config.timeout, config.max_retries, config.retry_interval]) + .to eq([5, 30, 3, 0.5]) + end + + it 'keeps configurable timeouts and retry budget' do + config = described_class.new(**valid_args, open_timeout: 1, timeout: 2, max_retries: 5, retry_interval: 0.1) + expect([config.open_timeout, config.timeout, config.max_retries, config.retry_interval]) + .to eq([1, 2, 5, 0.1]) + 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/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