Repository navigation
feat: one-key client for the new private key #155
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sydneycollins-cio
wants to merge
3
commits into
main
Choose a base branch
from
new-api-keys
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.