Skip to content
Open
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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions lib/customerio.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
147 changes: 147 additions & 0 deletions lib/customerio/unified_client.rb
Original file line number Diff line number Diff line change
@@ -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))
Comment thread
cursor[bot] marked this conversation as resolved.
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
200 changes: 200 additions & 0 deletions spec/unified_client_spec.rb
Original file line number Diff line number Diff line change
@@ -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