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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ GEM
multi_json
excon (1.4.0)
logger
faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-net_http (3.4.2)
net-http (~> 0.5)
faraday-retry (2.4.0)
faraday (~> 2.0)
http-2 (1.1.3)
httpx (1.7.3)
http-2 (>= 1.1.3)
json (2.19.0)
json-schema (6.2.0)
addressable (~> 2.8)
Expand All @@ -27,6 +38,8 @@ GEM
mcp (0.8.0)
json-schema (>= 4.1)
multi_json (1.19.1)
net-http (0.9.1)
uri (>= 0.11.1)
parallel (1.27.0)
parser (3.3.10.2)
ast (~> 2.4.1)
Expand Down Expand Up @@ -90,20 +103,26 @@ GEM
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)

PLATFORMS
arm64-darwin-25
ruby
x86_64-linux

DEPENDENCIES
altertable-ruby!
base64
faraday (~> 2.0)
faraday-net_http
faraday-retry
httpx
rake (~> 13.0)
rspec
rspec (~> 3.0, >= 0)
rubocop (~> 1.0)
rubocop-performance (~> 1.0)
rubocop-rspec (~> 2.0)
testcontainers

BUNDLED WITH
2.6.7
4.0.7
6 changes: 6 additions & 0 deletions altertable-ruby.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,10 @@ Gem::Specification.new do |spec|
spec.add_development_dependency "rubocop-performance", "~> 1.0"
spec.add_development_dependency "rubocop-rspec", "~> 2.0"
spec.add_development_dependency "testcontainers"

# Optional adapter support (development only)
spec.add_development_dependency "faraday", "~> 2.0"
spec.add_development_dependency "faraday-retry"
spec.add_development_dependency "faraday-net_http"
spec.add_development_dependency "httpx"
end
106 changes: 106 additions & 0 deletions lib/altertable/adapters.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
module Altertable
module Adapters
Response = Struct.new(:status, :body, :headers)

class Base
def initialize(base_url:, timeout:, headers: {})
@base_url = base_url
@timeout = timeout
@headers = headers
end

def post(path, body: nil, params: {}, &block)
raise NotImplementedError
end
end

class FaradayAdapter < Base
def initialize(base_url:, timeout:, headers: {})
super
require "faraday"
require "faraday/retry"
require "faraday/net_http"

@conn = Faraday.new(url: @base_url) do |f|
@headers.each { |k, v| f.headers[k] = v }
f.options.timeout = @timeout
f.request :retry, max: 3, interval: 0.05, backoff_factor: 2
f.adapter Faraday.default_adapter
end
end

def post(path, body: nil, params: {}, &block)
resp = @conn.post(path) do |req|
req.params = params
req.body = body
end
wrap_response(resp)
rescue Faraday::ConnectionFailed => e
raise Altertable::NetworkError.new(e.message, e)
rescue Faraday::TimeoutError => e
raise Altertable::NetworkError.new("Timeout: #{e.message}", e)
end

private

def wrap_response(resp)
Response.new(resp.status, resp.body, resp.headers)
end
end

class HttpxAdapter < Base
def initialize(base_url:, timeout:, headers: {})
super
require "httpx"
@client = HTTPX.plugin(:retries).with(
timeout: { operation_timeout: @timeout },
headers: @headers,
base_url: @base_url
)
end

def post(path, body: nil, params: {}, &block)
resp = @client.post(path, body: body, params: params)
wrap_response(resp)
rescue HTTPX::Error => e
raise Altertable::NetworkError.new(e.message, e)
end

private

def wrap_response(resp)
if resp.is_a?(HTTPX::ErrorResponse)
raise Altertable::NetworkError.new(resp.error.message, resp.error)
end
Response.new(resp.status, resp.to_s, resp.headers)
end
end

class NetHttpAdapter < Base
def initialize(base_url:, timeout:, headers: {})
super
require "net/http"
require "uri"
@uri = URI.parse(@base_url)
end

def post(path, body: nil, params: {}, &block)
uri = URI.join(@uri, path)
uri.query = URI.encode_www_form(params) unless params.empty?

req = Net::HTTP::Post.new(uri)
@headers.each { |k, v| req[k] = v }
req.body = body if body

Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout, read_timeout: @timeout) do |http|
resp = http.request(req)
Response.new(resp.code.to_i, resp.body, resp.to_hash)
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
raise Altertable::NetworkError.new("Timeout: #{e.message}", e)
rescue StandardError => e
raise Altertable::NetworkError.new(e.message, e)
end
end
end
end
64 changes: 43 additions & 21 deletions lib/altertable/client.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# frozen_string_literal: true

require "net/http"
require "json"
require "time"
require_relative "errors"
require_relative "adapters"

module Altertable
class Client
Expand All @@ -21,6 +21,14 @@ def initialize(api_key, options = {})
@release = options[:release]
@debug = options[:debug] || false
@on_error = options[:on_error]

# Initialize adapter
adapter_name = options[:adapter]
headers = {
"X-API-Key" => @api_key,
"Content-Type" => "application/json"
}
@adapter = select_adapter(adapter_name, { base_url: @base_url, timeout: @timeout, headers: headers })
end

def track(event, distinct_id, properties = {})
Expand Down Expand Up @@ -63,46 +71,60 @@ def alias(new_user_id, previous_id)

private

def post(path, payload)
uri = URI("#{@base_url}#{path}")
req = Net::HTTP::Post.new(uri)
req["X-API-Key"] = @api_key
req["Content-Type"] = "application/json"
req.body = payload.to_json

begin
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", read_timeout: @timeout) do |http|
http.request(req)
def select_adapter(name, options)
case name
when :faraday
Adapters::FaradayAdapter.new(**options)
when :httpx
Adapters::HttpxAdapter.new(**options)
when :net_http
Adapters::NetHttpAdapter.new(**options)
else
# Auto-detect
if defined?(Faraday) || try_require("faraday")
Adapters::FaradayAdapter.new(**options)
elsif defined?(HTTPX) || try_require("httpx")
Adapters::HttpxAdapter.new(**options)
else
Adapters::NetHttpAdapter.new(**options)
end

handle_response(res)
rescue StandardError => e
handle_error(e)
end
end

def try_require(gem_name)
require gem_name
true
rescue LoadError
false
end

def post(path, payload)
res = @adapter.post(path, body: payload.to_json)
handle_response(res)
rescue StandardError => e
handle_error(e)
end

def handle_response(res)
case res.code.to_i
case res.status
when 200..299
JSON.parse(res.body) rescue {}
when 422
error_data = JSON.parse(res.body) rescue {}
raise ApiError.new("Unprocessable Entity: #{error_data["message"]}", res.code, error_data)
raise ApiError.new("Unprocessable Entity: #{error_data["message"]}", res.status, error_data)
else
raise ApiError.new("HTTP Error: #{res.code}", res.code)
raise ApiError.new("HTTP Error: #{res.status}", res.status)
end
end

def handle_error(error)
wrapped_error = if error.is_a?(AltertableError)
error
elsif error.is_a?(Net::ReadTimeout) || error.is_a?(Net::OpenTimeout)
NetworkError.new("Timeout: #{error.message}", error)
else
AltertableError.new(error.message, error)
end

@on_error&.call(wrapped_error)
@on_error&.call(wrapped_error) if @on_error
raise wrapped_error
end
end
Expand Down
30 changes: 30 additions & 0 deletions spec/adapter_selection_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe Altertable::Client do
describe "#initialize adapter selection" do
let(:api_key) { "test_api_key" }
let(:base_url) { "https://api.test.local" }

context "when adapter is explicitly provided" do
it "uses FaradayAdapter when :faraday is requested" do
client = described_class.new(api_key, base_url: base_url, adapter: :faraday)
adapter = client.instance_variable_get(:@adapter)
expect(adapter).to be_a(Altertable::Adapters::FaradayAdapter)
end

it "uses HttpxAdapter when :httpx is requested" do
client = described_class.new(api_key, base_url: base_url, adapter: :httpx)
adapter = client.instance_variable_get(:@adapter)
expect(adapter).to be_a(Altertable::Adapters::HttpxAdapter)
end

it "uses NetHttpAdapter when :net_http is requested" do
client = described_class.new(api_key, base_url: base_url, adapter: :net_http)
adapter = client.instance_variable_get(:@adapter)
expect(adapter).to be_a(Altertable::Adapters::NetHttpAdapter)
end
end
end
end