diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e927af6..431623b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: ports: - 15001:15001 env: - ALTERTABLE_MOCK_API_KEY: test_pk_abc123 + ALTERTABLE_MOCK_API_KEYS: test_pk_abc123 options: >- --health-cmd "exit 0" --health-interval 5s diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..61b1131 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "specs"] + path = specs + url = https://github.com/altertable-ai/altertable-client-specs.git diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..7b43cb3 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,18 @@ +require: + - rubocop-performance + - rubocop-rspec + +AllCops: + NewCops: enable + Exclude: + - 'vendor/**/*' + - 'spec/spec_helper.rb' + +Layout/LineLength: + Max: 120 + +Style/Documentation: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..3491c9e --- /dev/null +++ b/Gemfile @@ -0,0 +1,7 @@ +source "https://rubygems.org" + +gemspec + +gem "rspec" +gem "testcontainers" +gem "base64" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..009a753 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,109 @@ +PATH + remote: . + specs: + altertable-ruby (0.1.0) + +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.9) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (4.0.1) + diff-lcs (1.6.2) + docker-api (2.4.0) + excon (>= 0.64.0) + multi_json + excon (1.4.0) + logger + json (2.19.0) + json-schema (6.2.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + mcp (0.8.0) + json-schema (>= 4.1) + multi_json (1.19.1) + parallel (1.27.0) + parser (3.3.10.2) + ast (~> 2.4.1) + racc + prism (1.9.0) + public_suffix (7.0.5) + racc (1.8.1) + rainbow (3.1.1) + rake (13.3.1) + regexp_parser (2.11.3) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.85.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + mcp (~> 0.6) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-capybara (2.22.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-factory_bot (2.28.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rspec (2.31.0) + rubocop (~> 1.40) + rubocop-capybara (~> 2.17) + rubocop-factory_bot (~> 2.22) + rubocop-rspec_rails (~> 2.28) + rubocop-rspec_rails (2.29.1) + rubocop (~> 1.61) + ruby-progressbar (1.13.0) + testcontainers (0.2.0) + testcontainers-core (= 0.2.0) + testcontainers-core (0.2.0) + docker-api (~> 2.2) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + altertable-ruby! + base64 + rake (~> 13.0) + rspec + rubocop (~> 1.0) + rubocop-performance (~> 1.0) + rubocop-rspec (~> 2.0) + testcontainers + +BUNDLED WITH + 2.6.7 diff --git a/README.md b/README.md new file mode 100644 index 0000000..50f1b13 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Altertable Ruby SDK + +Official Ruby SDK for Altertable Product Analytics. + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'altertable-ruby' +``` + +And then execute: + + $ bundle install + +## Usage + +### Initialization + +```ruby +require 'altertable' + +Altertable.init('your_api_key', { + environment: 'production' +}) +``` + +### Tracking Events + +```ruby +Altertable.track('button_clicked', 'user_123', { + button_id: 'signup_btn', + page: 'home' +}) +``` + +### Identifying Users + +```ruby +Altertable.identify('user_123', { + email: 'user@example.com', + name: 'John Doe' +}) +``` + +### Alias + +```ruby +Altertable.alias('new_user_id', 'previous_anonymous_id') +``` + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..932c257 --- /dev/null +++ b/Rakefile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/altertable-ruby.gemspec b/altertable-ruby.gemspec new file mode 100644 index 0000000..e44307f --- /dev/null +++ b/altertable-ruby.gemspec @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +Gem::Specification.new do |spec| + spec.name = "altertable-ruby" + spec.version = "0.1.0" + spec.authors = ["Altertable"] + spec.email = ["support@api.altertable.ai"] + + spec.summary = "Altertable Product Analytics Ruby SDK" + spec.description = "Official Ruby client for Altertable Product Analytics" + spec.homepage = "https://github.com/altertable-ai/altertable-ruby" + spec.license = "MIT" + spec.required_ruby_version = Gem::Requirement.new(">= 2.6.0") + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "https://github.com/altertable-ai/altertable-ruby/blob/main/CHANGELOG.md" + + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_development_dependency "rake", "~> 13.0" + spec.add_development_dependency "rspec", "~> 3.0" + spec.add_development_dependency "rubocop", "~> 1.0" + spec.add_development_dependency "rubocop-performance", "~> 1.0" + spec.add_development_dependency "rubocop-rspec", "~> 2.0" + spec.add_development_dependency "testcontainers" +end diff --git a/lib/altertable.rb b/lib/altertable.rb new file mode 100644 index 0000000..406be3f --- /dev/null +++ b/lib/altertable.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative "altertable/version" +require_relative "altertable/errors" +require_relative "altertable/client" + +module Altertable + class << self + def init(api_key, options = {}) + @client = Client.new(api_key, options) + end + + def track(event, user_id, properties = {}) + client.track(event, user_id, properties) + end + + def identify(user_id, traits = {}) + client.identify(user_id, traits) + end + + def alias(new_user_id, previous_id) + client.alias(new_user_id, previous_id) + end + + def client + raise ConfigurationError, "Altertable client not initialized. Call Altertable.init(api_key) first." unless @client + + @client + end + end +end diff --git a/lib/altertable/client.rb b/lib/altertable/client.rb new file mode 100644 index 0000000..cdb2085 --- /dev/null +++ b/lib/altertable/client.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "net/http" +require "json" +require "time" +require_relative "errors" + +module Altertable + class Client + DEFAULT_BASE_URL = "https://api.altertable.ai" + DEFAULT_TIMEOUT = 5 + DEFAULT_ENVIRONMENT = "production" + + def initialize(api_key, options = {}) + raise ConfigurationError, "API Key is required" if api_key.nil? || api_key.empty? + + @api_key = api_key + @base_url = options[:base_url] || DEFAULT_BASE_URL + @environment = options[:environment] || DEFAULT_ENVIRONMENT + @timeout = options[:request_timeout] || DEFAULT_TIMEOUT + @release = options[:release] + @debug = options[:debug] || false + @on_error = options[:on_error] + end + + def track(event, distinct_id, properties = {}) + payload = { + timestamp: Time.now.utc.iso8601(3), + event: event, + environment: @environment, + distinct_id: distinct_id, + properties: { + "$lib": "altertable-ruby", + "$lib_version": Altertable::VERSION + }.merge(properties) + } + payload[:properties]["$release"] = @release if @release + + post("/track", payload) + end + + def identify(user_id, traits = {}) + payload = { + timestamp: Time.now.utc.iso8601(3), + environment: @environment, + distinct_id: user_id, + traits: traits + } + + post("/identify", payload) + end + + def alias(new_user_id, previous_id) + payload = { + timestamp: Time.now.utc.iso8601(3), + environment: @environment, + distinct_id: previous_id, + new_user_id: new_user_id + } + + post("/alias", payload) + end + + 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) + end + + handle_response(res) + rescue StandardError => e + handle_error(e) + end + end + + def handle_response(res) + case res.code.to_i + 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) + else + raise ApiError.new("HTTP Error: #{res.code}", res.code) + 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) + raise wrapped_error + end + end +end diff --git a/lib/altertable/errors.rb b/lib/altertable/errors.rb new file mode 100644 index 0000000..ddb547f --- /dev/null +++ b/lib/altertable/errors.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Altertable + class AltertableError < StandardError + attr_reader :cause + + def initialize(message, cause = nil) + super(message) + @cause = cause + end + end + + class ConfigurationError < AltertableError; end + + class ApiError < AltertableError + attr_reader :status, :details + + def initialize(message, status, details = {}) + super(message) + @status = status + @details = details + end + end + + class NetworkError < AltertableError; end +end diff --git a/lib/altertable/version.rb b/lib/altertable/version.rb new file mode 100644 index 0000000..0c36ef4 --- /dev/null +++ b/lib/altertable/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Altertable + VERSION = "0.1.0" +end diff --git a/spec/altertable_spec.rb b/spec/altertable_spec.rb new file mode 100644 index 0000000..a698e3e --- /dev/null +++ b/spec/altertable_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "spec_helper" + +MOCK_BASE_URL = "http://localhost:#{ENV.fetch("ALTERTABLE_MOCK_PORT", 15001)}" + +RSpec.describe Altertable do + let(:api_key) { "test_pk_abc123" } + + before do + Altertable.init(api_key, base_url: MOCK_BASE_URL) + end + + it "has a version number" do + expect(Altertable::VERSION).not_to be_nil + end + + describe ".track" do + it "sends a track request" do + response = Altertable.track( + "test_event", + "user_123", + { key: "value" } + ) + expect(response).to include("ok" => true) + end + end + + describe ".identify" do + it "sends an identify request" do + response = Altertable.identify( + "user_123", + { email: "test@example.com" } + ) + expect(response).to include("ok" => true) + end + end + + describe ".alias" do + it "raises an error because the endpoint does not exist" do + expect { + Altertable.alias("new_id", "old_id") + }.to raise_error(Altertable::ApiError) + end + end + + describe "authentication" do + context "with wrong API key" do + before do + Altertable.init("wrong_api_key", base_url: MOCK_BASE_URL) + end + + it "raises an ApiError when tracking" do + expect { + Altertable.track("test_event", "user_123", { key: "value" }) + }.to raise_error(Altertable::ApiError) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..e01f195 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "altertable" +require_relative "support/altertable_container" + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups +end diff --git a/spec/support/altertable_container.rb b/spec/support/altertable_container.rb new file mode 100644 index 0000000..9ff533d --- /dev/null +++ b/spec/support/altertable_container.rb @@ -0,0 +1,18 @@ +require "testcontainers" + +# In CI the mock is provided as a GitHub Actions service container already +# bound to localhost:15001, so we skip spinning one up ourselves. +unless ENV["CI"] + container = Testcontainers::DockerContainer + .new("ghcr.io/altertable-ai/altertable-mock:latest", image_create_options: { "platform" => "linux/amd64" }) + .with_exposed_port(15001) + .with_env("ALTERTABLE_MOCK_API_KEYS", "test_pk_abc123") + .with_wait_for(:logs, /Starting Product Analytics HTTP server/, timeout: 30) + + container.start + + mapped_port = container.mapped_port(15001) + ENV["ALTERTABLE_MOCK_PORT"] = mapped_port.to_s + + at_exit { container.stop } +end diff --git a/specs b/specs new file mode 160000 index 0000000..f593ec3 --- /dev/null +++ b/specs @@ -0,0 +1 @@ +Subproject commit f593ec3f89f7d2a9796863cc258e954b58c6ff1d