diff --git a/README.md b/README.md index 3f43173..3ec7e59 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,36 @@ In the end, the decision on whether or not to use the API client or the JavaScript snippet should be based on what works best for you. You'll be able to integrate **fully** with [Customer.io](https://customer.io) with either approach. +### Quick start + +Create a client with your secret key (`sk_…`). It sends tracking calls to the Track API and transactional messages to the App API, and picks the US or EU region from the key. + +```ruby +cio = Customerio::UnifiedClient.new(ENV["CIO_API_KEY"]) + +cio.identify(id: "user_123", email: "a@example.com") +cio.track("user_123", "order_completed") +cio.send_email(Customerio::SendEmailRequest.new( + to: "a@example.com", + identifiers: { id: "user_123" }, + transactional_message_id: 5 +)) +``` + +Pass `region:` to override the region in the key. Legacy App API keys also work here; they default to `Customerio::Regions::US` unless you pass `region:`. + +`identify`, `track`, `track_anonymous`, `pageview`, the `send_*` methods and `trigger_broadcast` are on the client directly. The rest of the Track and App API are on `cio.track_client` and `cio.api_client`. + +If the key can't do what you asked, the call raises `Customerio::KeyCapabilityError`. A public key (`pk_…`) raises it before any request when you send a transactional message. A 401 or 403 from the server is re-raised as a `KeyCapabilityError` too, with the original error as `cause`. + +```ruby +begin + cio.send_email(request) +rescue Customerio::KeyCapabilityError => e + puts e.capability, e.message # :transactional, "This key can't send transactional messages ..." +end +``` + ### Setup Create an instance of the client with your [Customer.io credentials](https://fly.customer.io/settings/api_credentials). diff --git a/lib/customerio.rb b/lib/customerio.rb index 98bed86..e8fe320 100644 --- a/lib/customerio.rb +++ b/lib/customerio.rb @@ -14,4 +14,5 @@ module Customerio require "customerio/requests/send_in_app_request" require "customerio/requests/trigger_broadcast_request" require "customerio/api" + require "customerio/unified_client" end diff --git a/lib/customerio/unified_client.rb b/lib/customerio/unified_client.rb new file mode 100644 index 0000000..d4f3ef6 --- /dev/null +++ b/lib/customerio/unified_client.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +module Customerio + # Raised by UnifiedClient when its key can't do what a method asks. + # + # Raised before the request when the key's type rules it out (a public `pk_` + # key can't send transactional messages). Otherwise the server's 401 or 403 + # is re-raised as this error, with the original InvalidResponse as `cause`. + class KeyCapabilityError < StandardError + # What the key can't do: :track or :transactional. + attr_reader :capability + # The HTTP status code, when the server refused the key. + attr_reader :code + + def initialize(capability, message, code = nil) + @capability = capability + @code = code + + super(message) + end + end + + # One client for tracking and transactional messages, authenticated with a + # single key sent as a Bearer token. + # + # Tracking methods go to the Track API; send methods go to the App API. For + # `sk_us_…` / `sk_eu_…` keys the region comes from the key; an explicit + # `region` wins, and legacy keys default to Regions::US. + # + # Other Track and App API methods are on #track_client and #api_client. + class UnifiedClient + # Keys in the shared format look like `{type}_{us|eu}_{random}_{checksum}`, + # where type is `pk` (public) or `sk` (secret). The server checks the + # checksum; the client only reads the prefix. + KEY_PREFIX = /\A([a-z]{2})_(us|eu)_/ + + CAPABILITY_ACTIONS = { + track: "send tracking data", + transactional: "send transactional messages" + }.freeze + + attr_reader :track_client, :region + + def initialize(api_key, options = {}) + api_key = api_key.to_s.strip + raise ArgumentError, "api_key is required" if api_key.empty? + + match = KEY_PREFIX.match(api_key) + @key_kind = match ? match[1].to_sym : :legacy + @region = options[:region] || (match && match[2] == "eu" ? Regions::EU : Regions::US) + + @track_client = BearerTrackClient.new(api_key, track_options(options)) + @api_client = APIClient.new(api_key, api_options(options)) + end + + # The App API client. Raises KeyCapabilityError for a public `pk_` key. + def api_client + if @key_kind == :pk + raise KeyCapabilityError.new( + :transactional, + "This key can't use the App API: public keys (pk_) are for tracking only. Use a secret key (sk_)." + ) + end + + @api_client + end + + def identify(attributes) + guard(:track) { track_client.identify(attributes) } + end + + def track(customer_id, event_name, attributes = {}, id: nil, timestamp: nil) + guard(:track) { track_client.track(customer_id, event_name, attributes, id: id, timestamp: timestamp) } + end + + def track_anonymous(anonymous_id, event_name, attributes = {}, id: nil, timestamp: nil) + guard(:track) { track_client.track_anonymous(anonymous_id, event_name, attributes, id: id, timestamp: timestamp) } + end + + def pageview(customer_id, page, attributes = {}) + guard(:track) { track_client.pageview(customer_id, page, attributes) } + end + + def send_email(req) + guard(:transactional) { api_client.send_email(req) } + end + + def send_push(req) + guard(:transactional) { api_client.send_push(req) } + end + + def send_sms(req) + guard(:transactional) { api_client.send_sms(req) } + end + + def send_whatsapp(req) + guard(:transactional) { api_client.send_whatsapp(req) } + end + + def send_inbox_message(req) + guard(:transactional) { api_client.send_inbox_message(req) } + end + + def send_in_app(req) + guard(:transactional) { api_client.send_in_app(req) } + end + + def trigger_broadcast(req) + guard(:transactional) { api_client.trigger_broadcast(req) } + end + + private + + def track_options(options) + options.except(:api_url).merge(region: @region, url: options[:track_url]) + end + + def api_options(options) + options.except(:track_url).merge(region: @region, url: options[:api_url]) + end + + # Turns a 401/403 into a KeyCapabilityError so a key mismatch is never a + # bare auth failure. The original error is kept as `cause`. + def guard(capability) + yield + rescue InvalidResponse => e + raise unless %w[401 403].include?(e.code.to_s) + + raise KeyCapabilityError.new( + capability, + "This key can't #{CAPABILITY_ACTIONS[capability]} (#{e.code}): #{e.message}", + e.code + ) + end + + # Client only speaks Basic auth, so swap in a Bearer transport. + class BearerTrackClient < Client + def initialize(api_key, options) + super(nil, nil, options) + + options = options.merge(url: options[:region].track_url) if options[:url].nil? || options[:url].empty? + @client = BaseClient.new({ app_key: api_key }, options) + end + end + private_constant :BearerTrackClient + end +end diff --git a/spec/unified_client_spec.rb b/spec/unified_client_spec.rb new file mode 100644 index 0000000..aabcebe --- /dev/null +++ b/spec/unified_client_spec.rb @@ -0,0 +1,200 @@ +require 'spec_helper' +require 'json' + +describe Customerio::UnifiedClient do + let(:us_key) { "sk_us_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" } + let(:eu_key) { "sk_eu_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" } + let(:public_key) { "pk_us_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" } + let(:legacy_key) { "abc123" } + + let(:email_request) do + Customerio::SendEmailRequest.new(to: "a@example.com", identifiers: { id: "user_123" }, transactional_message_id: 5) + end + + def bearer(key) + { 'Authorization' => "Bearer #{key}" } + end + + it "requires an api key" do + expect { Customerio::UnifiedClient.new("") }.to raise_error(ArgumentError, "api_key is required") + expect { Customerio::UnifiedClient.new(nil) }.to raise_error(ArgumentError, "api_key is required") + end + + it "raises an error when an incorrect region is passed in" do + expect { + Customerio::UnifiedClient.new(us_key, region: :au) + }.to raise_error(ArgumentError, /region must be an instance of Customerio::Regions::Region/) + end + + describe "region" do + it "comes from the key" do + expect(Customerio::UnifiedClient.new(us_key).region).to eq(Customerio::Regions::US) + expect(Customerio::UnifiedClient.new(eu_key).region).to eq(Customerio::Regions::EU) + expect(Customerio::UnifiedClient.new(public_key).region).to eq(Customerio::Regions::US) + end + + it "uses an explicit region over the key" do + expect(Customerio::UnifiedClient.new(eu_key, region: Customerio::Regions::US).region).to eq(Customerio::Regions::US) + end + + it "keeps the region option and US default for legacy keys" do + expect(Customerio::UnifiedClient.new(legacy_key).region).to eq(Customerio::Regions::US) + expect(Customerio::UnifiedClient.new(legacy_key, region: Customerio::Regions::EU).region).to eq(Customerio::Regions::EU) + end + + it "ignores whitespace around the key" do + client = Customerio::UnifiedClient.new(" #{eu_key}\n") + expect(client.region).to eq(Customerio::Regions::EU) + + stub = stub_request(:put, "https://track-eu.customer.io/api/v1/customers/user_123") + .with(headers: bearer(eu_key)) + .to_return(status: 200, body: "", headers: {}) + + client.identify(id: "user_123", email: "a@example.com") + expect(stub).to have_been_requested + end + end + + describe "tracking" do + let(:client) { Customerio::UnifiedClient.new(us_key) } + + it "sends identify to the Track API with Bearer" do + stub = stub_request(:put, "https://track.customer.io/api/v1/customers/user_123") + .with(headers: bearer(us_key), body: { id: "user_123", email: "a@example.com" }) + .to_return(status: 200, body: "") + + client.identify(id: "user_123", email: "a@example.com") + expect(stub).to have_been_requested + end + + it "sends track, track_anonymous and pageview to the Track API with Bearer" do + customer_events = stub_request(:post, "https://track.customer.io/api/v1/customers/user_123/events") + .with(headers: bearer(us_key)) + .to_return(status: 200, body: "") + anonymous_events = stub_request(:post, "https://track.customer.io/api/v1/events") + .with(headers: bearer(us_key), body: { name: "viewed", data: {}, anonymous_id: "anon_1" }) + .to_return(status: 200, body: "") + + client.track("user_123", "order_completed") + client.pageview("user_123", "/pricing") + client.track_anonymous("anon_1", "viewed") + + expect(customer_events).to have_been_requested.twice + expect(anonymous_events).to have_been_requested + end + + it "lets a public key track" do + stub = stub_request(:put, "https://track.customer.io/api/v1/customers/user_123") + .with(headers: bearer(public_key)) + .to_return(status: 200, body: "") + + Customerio::UnifiedClient.new(public_key).identify(id: "user_123") + expect(stub).to have_been_requested + end + end + + describe "transactional" do + let(:client) { Customerio::UnifiedClient.new(us_key) } + + it "sends email to the App API with Bearer" do + stub_request(:post, "https://api.customer.io/v1/send/email") + .with(headers: bearer(us_key), body: email_request.message) + .to_return(status: 200, body: { delivery_id: 1 }.to_json) + + expect(client.send_email(email_request)).to eq({ "delivery_id" => 1 }) + end + + { + send_push: [Customerio::SendPushRequest, { transactional_message_id: 1, identifiers: { id: "c1" } }, "/v1/send/push"], + send_sms: [Customerio::SendSMSRequest, { transactional_message_id: 1, identifiers: { id: "c1" } }, "/v1/send/sms"], + send_whatsapp: [Customerio::SendWhatsAppRequest, { transactional_message_id: 1, identifiers: { id: "c1" } }, "/v1/send/whatsapp"], + send_inbox_message: [Customerio::SendInboxMessageRequest, { transactional_message_id: 1, identifiers: { id: "c1" } }, "/v1/send/inbox_message"], + send_in_app: [Customerio::SendInAppRequest, { transactional_message_id: 1, identifiers: { id: "c1" } }, "/v1/send/in_app"], + trigger_broadcast: [Customerio::TriggerBroadcastRequest, { broadcast_id: 12 }, "/v1/campaigns/12/triggers"] + }.each do |method, (request_class, opts, path)| + it "sends #{method} to the App API with Bearer" do + stub = stub_request(:post, "https://api.customer.io#{path}") + .with(headers: bearer(us_key)) + .to_return(status: 200, body: { delivery_id: 1 }.to_json) + + client.public_send(method, request_class.new(opts)) + expect(stub).to have_been_requested + end + end + + it "uses EU hosts for an sk_eu_ key" do + track = stub_request(:put, "https://track-eu.customer.io/api/v1/customers/user_123").to_return(status: 200, body: "") + api = stub_request(:post, "https://api-eu.customer.io/v1/send/email").to_return(status: 200, body: "{}") + + client = Customerio::UnifiedClient.new(eu_key) + client.identify(id: "user_123") + client.send_email(email_request) + + expect(track).to have_been_requested + expect(api).to have_been_requested + end + + it "lets custom URLs override the region hosts" do + track = stub_request(:put, "http://track.test/api/v1/customers/1").to_return(status: 200, body: "") + api = stub_request(:post, "http://api.test/v1/send/email").to_return(status: 200, body: "{}") + + client = Customerio::UnifiedClient.new(us_key, track_url: "http://track.test", api_url: "http://api.test") + client.identify(id: "1") + client.send_email(email_request) + + expect(track).to have_been_requested + expect(api).to have_been_requested + end + end + + describe "public keys" do + let(:client) { Customerio::UnifiedClient.new(public_key) } + + it "raise KeyCapabilityError before sending a transactional message" do + expect { client.send_email(email_request) }.to raise_error(Customerio::KeyCapabilityError) { |error| + expect(error.capability).to eq(:transactional) + expect(error.code).to be_nil + } + expect(a_request(:any, /customer\.io/)).not_to have_been_made + end + + it "raise KeyCapabilityError from api_client" do + expect { client.api_client } + .to raise_error(Customerio::KeyCapabilityError, /public keys \(pk_\).*secret key \(sk_\)/) + end + end + + describe "server refusals" do + let(:client) { Customerio::UnifiedClient.new(us_key) } + + [401, 403].each do |status| + it "re-raises a #{status} from the Track API as KeyCapabilityError" do + stub_request(:put, "https://track.customer.io/api/v1/customers/1").to_return(status: status, body: "forbidden") + + expect { client.identify(id: "1") }.to raise_error(Customerio::KeyCapabilityError) { |error| + expect(error.capability).to eq(:track) + expect(error.code).to eq(status.to_s) + expect(error.message).to eq("This key can't send tracking data (#{status}): forbidden") + expect(error.cause).to be_a(Customerio::InvalidResponse) + } + end + + it "re-raises a #{status} from the App API as KeyCapabilityError" do + stub_request(:post, "https://api.customer.io/v1/send/email").to_return(status: status, body: "nope") + + expect { client.send_email(email_request) }.to raise_error(Customerio::KeyCapabilityError) { |error| + expect(error.capability).to eq(:transactional) + expect(error.code).to eq(status.to_s) + expect(error.message).to eq("This key can't send transactional messages (#{status}): nope") + expect(error.cause).to be_a(Customerio::InvalidResponse) + } + end + end + + it "leaves other errors alone" do + stub_request(:put, "https://track.customer.io/api/v1/customers/1").to_return(status: 500, body: "boom") + + expect { client.identify(id: "1") }.to raise_error(Customerio::InvalidResponse) + end + end +end