clearbit 0.2.8 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 9bfa4e2e52a22c81346477b50bbd5d4152abf27a
4
- data.tar.gz: 0ddaa5a863838daf609679587149fdc32ee9af40
2
+ SHA256:
3
+ metadata.gz: 7e0b00b7a895da02d3d5ca094f8960a954b5ecde28891979a515015608c2fd6d
4
+ data.tar.gz: e4e411ccc729b972cf7139594e59da959ff22222f82fb53e312c1b529512e7ee
5
5
  SHA512:
6
- metadata.gz: 2f1a861b55adb44a91ac887d53099166336e3373ffe48d945806c5d83f86f091b2fc8e0f6cb123160b2f09e6580377fd2dd92353d79c2338aa957f6269e7a1c7
7
- data.tar.gz: bfa3ffd82393968ae7e736d2e4bcee05acbb18b0a17413fcd6262aff1810f80187ba3c9df0585ce720aa9fc11f184ec441336ee04cede5f9a5aa72cbd6f0f510
6
+ metadata.gz: 368ac6459e7e2613e2636731fef9db010d3ae30c9772b7d57017c24d96b4760c9a1af6cddfcfbe6b793051e07552194c519d4a201883f04ec861185b8d5e475a
7
+ data.tar.gz: '08f475f7c73ccd1c77dd5b660f9c061bc1531402e1d982cf8f2142d5094fe7354087da8a27f50ed30e9562feb29c67b607495e7399d366e1d480b1915f150efa'
@@ -0,0 +1,3 @@
1
+ * @bentona
2
+ * @jcutrell
3
+
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2017 Clearbit.
3
+ Copyright (c) 2018 Clearbit.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -35,9 +35,36 @@ person = result.person
35
35
  company = result.company
36
36
  ```
37
37
 
38
- Passing the `stream` option makes the operation blocking - it could hang for 4-5 seconds if we haven't seen the email before. Alternatively you can use our [webhook](https://clearbit.com/docs#webhooks) API. If a person or company can't be found, then they'll be `nil`.
38
+ Passing the `stream` option makes the operation blocking - it could hang for 4-5 seconds if we haven't seen the email before. Alternatively you can use our [webhook](https://clearbit.com/docs#webhooks) API.
39
+
40
+ Without the `stream` option, the operation is non-blocking, and we will immediately return either the enriched data or `Clearbit::Pending` object.
41
+
42
+ ```ruby
43
+ result = Clearbit::Enrichment.find(email: 'alex@alexmaccaw.com')
44
+
45
+ if result.pending?
46
+ # Lookup queued - try again later
47
+ end
48
+
49
+ # Later
50
+ unless result.pending?
51
+ person = result.person
52
+ company = result.company
53
+ end
54
+
55
+ ```
56
+
57
+ In either case, if a person or company can't be found, the result will be `nil`.
39
58
 
40
59
  See the [documentation](https://clearbit.com/docs#person-api) for more information.
60
+ ## Name to Domain
61
+
62
+ To find the domain based on the name of a resource, you can use the `NameDomain` API.
63
+
64
+ ```ruby
65
+ name = Clearbit::NameDomain.find(name: 'Uber')
66
+ ```
67
+ For more information look at the [documentation](https://dashboard.clearbit.com/docs?ruby#name-to-domain-api).
41
68
 
42
69
  ## Company lookup
43
70
 
@@ -51,6 +78,22 @@ If the company can't be found, then `nil` will be returned.
51
78
 
52
79
  See the [documentation](https://clearbit.com/docs#company-api) for more information.
53
80
 
81
+ ## Analytics
82
+
83
+ Identify users by sending their `user_id`, and adding details like their `email` and `company_domain` to create People and Companies inside of Clearbit X.
84
+
85
+ ```ruby
86
+ Clearbit::Analytics.identify(
87
+ user_id: '1234', # Required
88
+ traits: {
89
+ email: 'david@clearbitexample.com', # Optional, but strongly recommended
90
+ company_domain: 'clearbit.com', # Optional, but strongly recommended
91
+ first_name: 'David', # Optional
92
+ last_name: 'Lumley', # Optional
93
+ },
94
+ )
95
+ ```
96
+
54
97
  ## Other APIs
55
98
 
56
99
  For more info on our other APIs (such as the Watchlist or Discover APIs), please see our [main documentation](https://clearbit.com/docs).
@@ -62,7 +105,7 @@ For rack apps use the `Clearbit::Webhook` module to wrap deserialization and ver
62
105
  ``` ruby
63
106
  post '/v1/webhooks/clearbit' do
64
107
  begin
65
- webhook = Clearbit::Webhook.new(env)
108
+ webhook = Clearbit::Webhook.new(request.env)
66
109
  webhook.type #=> 'person'
67
110
  webhook.body.name.given_name #=> 'Alex'
68
111
 
@@ -76,5 +119,16 @@ end
76
119
  The global Clearbit.key can be overriden for multi-tenant apps using multiple Clearbit keys like so:
77
120
 
78
121
  ```ruby
79
- webhook = Clearbit::Webhook.new(env, 'CLEARBIT_KEY')
122
+ webhook = Clearbit::Webhook.new(request.env, 'CLEARBIT_KEY')
123
+ ```
124
+
125
+ ## Proxy Support
126
+
127
+ Passing the proxy option allows you to specify a proxy server to pass the request through.
128
+
129
+ ``` ruby
130
+ company = Clearbit::Enrichment::Company.find(
131
+ domain: 'uber.com',
132
+ proxy: 'https://user:password@proxyserver.tld:8080'
133
+ )
80
134
  ```
@@ -18,6 +18,8 @@ Gem::Specification.new do |spec|
18
18
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
19
  spec.require_paths = ["lib"]
20
20
 
21
+ spec.add_dependency 'json', ['~> 1.7'] if RUBY_VERSION < '1.9'
22
+
21
23
  spec.add_development_dependency 'bundler', '~> 1.3'
22
24
  spec.add_development_dependency 'net-http-spy'
23
25
  spec.add_development_dependency 'pry'
@@ -1,8 +1,9 @@
1
1
  require 'clearbit'
2
- require 'pp'
3
2
 
4
- people = Clearbit::Prospector.search(domain: 'clearbit.com')
3
+ response = Clearbit::Prospector.search(domain: 'clearbit.com', page: 1)
5
4
 
6
- people.each do |person|
7
- puts [person.name.full_name, person.email]
5
+ puts "Displaying #{response[:results].size} of #{response[:total]} results:"
6
+
7
+ response[:results].each_with_index do |person, index|
8
+ puts " #{index + 1}. #{person.name.full_name} (#{person.email})"
8
9
  end
@@ -1,5 +1,6 @@
1
1
  require 'nestful'
2
2
  require 'clearbit/version'
3
+ require 'clearbit/analytics'
3
4
 
4
5
  module Clearbit
5
6
  def self.api_key=(value)
@@ -18,6 +19,7 @@ module Clearbit
18
19
  key || raise('Clearbit.key not set')
19
20
  end
20
21
 
22
+ autoload :Audiences, 'clearbit/audiences'
21
23
  autoload :Autocomplete, 'clearbit/autocomplete'
22
24
  autoload :Base, 'clearbit/base'
23
25
  autoload :Discovery, 'clearbit/discovery'
@@ -0,0 +1,46 @@
1
+ require 'clearbit/analytics/defaults'
2
+ require 'clearbit/analytics/utils'
3
+ require 'clearbit/analytics/field_parser'
4
+ require 'clearbit/analytics/client'
5
+ require 'clearbit/analytics/worker'
6
+ require 'clearbit/analytics/request'
7
+ require 'clearbit/analytics/response'
8
+ require 'clearbit/analytics/logging'
9
+
10
+ module Clearbit
11
+ class Analytics
12
+ # Proxy identify through to a client instance, in order to keep the client
13
+ # consistent with how the other Clearbit APIs are accessed
14
+ def self.identify(args)
15
+ analytics = new(write_key: Clearbit.key)
16
+ analytics.identify(args)
17
+ analytics.flush
18
+ end
19
+
20
+ # Initializes a new instance of {Clearbit::Analytics::Client}, to which all
21
+ # method calls are proxied.
22
+ #
23
+ # @param options includes options that are passed down to
24
+ # {Clearbit::Analytics::Client#initialize}
25
+ # @option options [Boolean] :stub (false) If true, requests don't hit the
26
+ # server and are stubbed to be successful.
27
+ def initialize(options = {})
28
+ Request.stub = options[:stub] if options.has_key?(:stub)
29
+ @client = Clearbit::Analytics::Client.new options
30
+ end
31
+
32
+ def method_missing(message, *args, &block)
33
+ if @client.respond_to? message
34
+ @client.send message, *args, &block
35
+ else
36
+ super
37
+ end
38
+ end
39
+
40
+ def respond_to_missing?(method_name, include_private = false)
41
+ @client.respond_to?(method_name) || super
42
+ end
43
+
44
+ include Logging
45
+ end
46
+ end
@@ -0,0 +1,10 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2013 Segment Inc. <friends@segment.com>
4
+ Copyright (c) 2019 Clearbit. <support@clearbit.com>
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9
+
10
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,49 @@
1
+ require 'clearbit/analytics/defaults'
2
+
3
+ module Clearbit
4
+ class Analytics
5
+ class BackoffPolicy
6
+ include Clearbit::Analytics::Defaults::BackoffPolicy
7
+
8
+ # @param [Hash] opts
9
+ # @option opts [Numeric] :min_timeout_ms The minimum backoff timeout
10
+ # @option opts [Numeric] :max_timeout_ms The maximum backoff timeout
11
+ # @option opts [Numeric] :multiplier The value to multiply the current
12
+ # interval with for each retry attempt
13
+ # @option opts [Numeric] :randomization_factor The randomization factor
14
+ # to use to create a range around the retry interval
15
+ def initialize(opts = {})
16
+ @min_timeout_ms = opts[:min_timeout_ms] || MIN_TIMEOUT_MS
17
+ @max_timeout_ms = opts[:max_timeout_ms] || MAX_TIMEOUT_MS
18
+ @multiplier = opts[:multiplier] || MULTIPLIER
19
+ @randomization_factor = opts[:randomization_factor] || RANDOMIZATION_FACTOR
20
+
21
+ @attempts = 0
22
+ end
23
+
24
+ # @return [Numeric] the next backoff interval, in milliseconds.
25
+ def next_interval
26
+ interval = @min_timeout_ms * (@multiplier**@attempts)
27
+ interval = add_jitter(interval, @randomization_factor)
28
+
29
+ @attempts += 1
30
+
31
+ [interval, @max_timeout_ms].min
32
+ end
33
+
34
+ private
35
+
36
+ def add_jitter(base, randomization_factor)
37
+ random_number = rand
38
+ max_deviation = base * randomization_factor
39
+ deviation = random_number * max_deviation
40
+
41
+ if random_number < 0.5
42
+ base - deviation
43
+ else
44
+ base + deviation
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,189 @@
1
+ require 'thread'
2
+ require 'time'
3
+
4
+ require 'clearbit/analytics/defaults'
5
+ require 'clearbit/analytics/logging'
6
+ require 'clearbit/analytics/utils'
7
+ require 'clearbit/analytics/worker'
8
+
9
+ module Clearbit
10
+ class Analytics
11
+ class Client
12
+ include Clearbit::Analytics::Utils
13
+ include Clearbit::Analytics::Logging
14
+
15
+ # @param [Hash] opts
16
+ # @option opts [String] :write_key Your project's write_key
17
+ # @option opts [FixNum] :max_queue_size Maximum number of calls to be
18
+ # remain queued.
19
+ # @option opts [Proc] :on_error Handles error calls from the API.
20
+ def initialize(opts = {})
21
+ symbolize_keys!(opts)
22
+
23
+ @queue = Queue.new
24
+ @write_key = opts[:write_key]
25
+ @max_queue_size = opts[:max_queue_size] || Defaults::Queue::MAX_SIZE
26
+ @worker_mutex = Mutex.new
27
+ @worker = Worker.new(@queue, @write_key, opts)
28
+
29
+ check_write_key!
30
+
31
+ at_exit { @worker_thread && @worker_thread[:should_exit] = true }
32
+ end
33
+
34
+ # Synchronously waits until the worker has flushed the queue.
35
+ #
36
+ # Use only for scripts which are not long-running, and will specifically
37
+ # exit
38
+ def flush
39
+ while !@queue.empty? || @worker.is_requesting?
40
+ ensure_worker_running
41
+ sleep(0.1)
42
+ end
43
+ end
44
+
45
+ # @!macro common_attrs
46
+ # @option attrs [String] :anonymous_id ID for a user when you don't know
47
+ # who they are yet. (optional but you must provide either an
48
+ # `anonymous_id` or `user_id`)
49
+ # @option attrs [Hash] :context ({})
50
+ # @option attrs [Hash] :integrations What integrations this event
51
+ # goes to (optional)
52
+ # @option attrs [String] :message_id ID that uniquely
53
+ # identifies a message across the API. (optional)
54
+ # @option attrs [Time] :timestamp When the event occurred (optional)
55
+ # @option attrs [String] :user_id The ID for this user in your database
56
+ # (optional but you must provide either an `anonymous_id` or `user_id`)
57
+ # @option attrs [Hash] :options Options such as user traits (optional)
58
+
59
+ # Tracks an event
60
+ #
61
+ # @see https://segment.com/docs/sources/server/ruby/#track
62
+ #
63
+ # @param [Hash] attrs
64
+ #
65
+ # @option attrs [String] :event Event name
66
+ # @option attrs [Hash] :properties Event properties (optional)
67
+ # @macro common_attrs
68
+ def track(attrs)
69
+ symbolize_keys! attrs
70
+ enqueue(FieldParser.parse_for_track(attrs))
71
+ end
72
+
73
+ # Identifies a user
74
+ #
75
+ # @see https://segment.com/docs/sources/server/ruby/#identify
76
+ #
77
+ # @param [Hash] attrs
78
+ #
79
+ # @option attrs [Hash] :traits User traits (optional)
80
+ # @macro common_attrs
81
+ def identify(attrs)
82
+ symbolize_keys! attrs
83
+ enqueue(FieldParser.parse_for_identify(attrs))
84
+ end
85
+
86
+ # Aliases a user from one id to another
87
+ #
88
+ # @see https://segment.com/docs/sources/server/ruby/#alias
89
+ #
90
+ # @param [Hash] attrs
91
+ #
92
+ # @option attrs [String] :previous_id The ID to alias from
93
+ # @macro common_attrs
94
+ def alias(attrs)
95
+ symbolize_keys! attrs
96
+ enqueue(FieldParser.parse_for_alias(attrs))
97
+ end
98
+
99
+ # Associates a user identity with a group.
100
+ #
101
+ # @see https://segment.com/docs/sources/server/ruby/#group
102
+ #
103
+ # @param [Hash] attrs
104
+ #
105
+ # @option attrs [String] :group_id The ID of the group
106
+ # @option attrs [Hash] :traits User traits (optional)
107
+ # @macro common_attrs
108
+ def group(attrs)
109
+ symbolize_keys! attrs
110
+ enqueue(FieldParser.parse_for_group(attrs))
111
+ end
112
+
113
+ # Records a page view
114
+ #
115
+ # @see https://segment.com/docs/sources/server/ruby/#page
116
+ #
117
+ # @param [Hash] attrs
118
+ #
119
+ # @option attrs [String] :name Name of the page
120
+ # @option attrs [Hash] :properties Page properties (optional)
121
+ # @macro common_attrs
122
+ def page(attrs)
123
+ symbolize_keys! attrs
124
+ enqueue(FieldParser.parse_for_page(attrs))
125
+ end
126
+
127
+ # Records a screen view (for a mobile app)
128
+ #
129
+ # @param [Hash] attrs
130
+ #
131
+ # @option attrs [String] :name Name of the screen
132
+ # @option attrs [Hash] :properties Screen properties (optional)
133
+ # @option attrs [String] :category The screen category (optional)
134
+ # @macro common_attrs
135
+ def screen(attrs)
136
+ symbolize_keys! attrs
137
+ enqueue(FieldParser.parse_for_screen(attrs))
138
+ end
139
+
140
+ # @return [Fixnum] number of messages in the queue
141
+ def queued_messages
142
+ @queue.length
143
+ end
144
+
145
+ private
146
+
147
+ # private: Enqueues the action.
148
+ #
149
+ # returns Boolean of whether the item was added to the queue.
150
+ def enqueue(action)
151
+ # add our request id for tracing purposes
152
+ action[:messageId] ||= uid
153
+
154
+ if @queue.length < @max_queue_size
155
+ @queue << action
156
+ ensure_worker_running
157
+
158
+ true
159
+ else
160
+ logger.warn(
161
+ 'Queue is full, dropping events. The :max_queue_size ' \
162
+ 'configuration parameter can be increased to prevent this from ' \
163
+ 'happening.'
164
+ )
165
+ false
166
+ end
167
+ end
168
+
169
+ # private: Checks that the write_key is properly initialized
170
+ def check_write_key!
171
+ raise ArgumentError, 'Write key must be initialized' if @write_key.nil?
172
+ end
173
+
174
+ def ensure_worker_running
175
+ return if worker_running?
176
+ @worker_mutex.synchronize do
177
+ return if worker_running?
178
+ @worker_thread = Thread.new do
179
+ @worker.run
180
+ end
181
+ end
182
+ end
183
+
184
+ def worker_running?
185
+ @worker_thread && @worker_thread.alive?
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,36 @@
1
+ module Clearbit
2
+ class Analytics
3
+ module Defaults
4
+ module Request
5
+ HOST = 'x.clearbit.com'
6
+ PORT = 443
7
+ PATH = '/v1/import'
8
+ SSL = true
9
+ HEADERS = { 'Accept' => 'application/json',
10
+ 'Content-Type' => 'application/json',
11
+ 'User-Agent' => "clearbit-ruby/#{Clearbit::VERSION}" }
12
+ RETRIES = 10
13
+ end
14
+
15
+ module Queue
16
+ MAX_SIZE = 10000
17
+ end
18
+
19
+ module Message
20
+ MAX_BYTES = 32768 # 32Kb
21
+ end
22
+
23
+ module MessageBatch
24
+ MAX_BYTES = 512_000 # 500Kb
25
+ MAX_SIZE = 100
26
+ end
27
+
28
+ module BackoffPolicy
29
+ MIN_TIMEOUT_MS = 100
30
+ MAX_TIMEOUT_MS = 10000
31
+ MULTIPLIER = 1.5
32
+ RANDOMIZATION_FACTOR = 0.5
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,192 @@
1
+ module Clearbit
2
+ class Analytics
3
+ # Handles parsing fields according to the Segment Spec
4
+ #
5
+ # @see https://segment.com/docs/spec/
6
+ class FieldParser
7
+ class << self
8
+ include Clearbit::Analytics::Utils
9
+
10
+ # In addition to the common fields, track accepts:
11
+ #
12
+ # - "event"
13
+ # - "properties"
14
+ def parse_for_track(fields)
15
+ common = parse_common_fields(fields)
16
+
17
+ event = fields[:event]
18
+ properties = fields[:properties] || {}
19
+
20
+ check_presence!(event, 'event')
21
+ check_is_hash!(properties, 'properties')
22
+
23
+ isoify_dates! properties
24
+
25
+ common.merge({
26
+ :type => 'track',
27
+ :event => event.to_s,
28
+ :properties => properties
29
+ })
30
+ end
31
+
32
+ # In addition to the common fields, identify accepts:
33
+ #
34
+ # - "traits"
35
+ def parse_for_identify(fields)
36
+ common = parse_common_fields(fields)
37
+
38
+ traits = fields[:traits] || {}
39
+ check_is_hash!(traits, 'traits')
40
+ isoify_dates! traits
41
+
42
+ common.merge({
43
+ :type => 'identify',
44
+ :traits => traits
45
+ })
46
+ end
47
+
48
+ # In addition to the common fields, alias accepts:
49
+ #
50
+ # - "previous_id"
51
+ def parse_for_alias(fields)
52
+ common = parse_common_fields(fields)
53
+
54
+ previous_id = fields[:previous_id]
55
+ check_presence!(previous_id, 'previous_id')
56
+
57
+ common.merge({
58
+ :type => 'alias',
59
+ :previousId => previous_id
60
+ })
61
+ end
62
+
63
+ # In addition to the common fields, group accepts:
64
+ #
65
+ # - "group_id"
66
+ # - "traits"
67
+ def parse_for_group(fields)
68
+ common = parse_common_fields(fields)
69
+
70
+ group_id = fields[:group_id]
71
+ traits = fields[:traits] || {}
72
+
73
+ check_presence!(group_id, 'group_id')
74
+ check_is_hash!(traits, 'traits')
75
+
76
+ isoify_dates! traits
77
+
78
+ common.merge({
79
+ :type => 'group',
80
+ :groupId => group_id,
81
+ :traits => traits
82
+ })
83
+ end
84
+
85
+ # In addition to the common fields, page accepts:
86
+ #
87
+ # - "name"
88
+ # - "properties"
89
+ def parse_for_page(fields)
90
+ common = parse_common_fields(fields)
91
+
92
+ name = fields[:name] || ''
93
+ properties = fields[:properties] || {}
94
+
95
+ check_is_hash!(properties, 'properties')
96
+
97
+ isoify_dates! properties
98
+
99
+ common.merge({
100
+ :type => 'page',
101
+ :name => name.to_s,
102
+ :properties => properties
103
+ })
104
+ end
105
+
106
+ # In addition to the common fields, screen accepts:
107
+ #
108
+ # - "name"
109
+ # - "properties"
110
+ # - "category" (Not in spec, retained for backward compatibility"
111
+ def parse_for_screen(fields)
112
+ common = parse_common_fields(fields)
113
+
114
+ name = fields[:name]
115
+ properties = fields[:properties] || {}
116
+ category = fields[:category]
117
+
118
+ check_presence!(name, 'name')
119
+ check_is_hash!(properties, 'properties')
120
+
121
+ isoify_dates! properties
122
+
123
+ parsed = common.merge({
124
+ :type => 'screen',
125
+ :name => name,
126
+ :properties => properties
127
+ })
128
+
129
+ parsed[:category] = category if category
130
+
131
+ parsed
132
+ end
133
+
134
+ private
135
+
136
+ def parse_common_fields(fields)
137
+ timestamp = fields[:timestamp] || Time.new
138
+ message_id = fields[:message_id].to_s if fields[:message_id]
139
+ context = fields[:context] || {}
140
+
141
+ check_user_id! fields
142
+ check_timestamp! timestamp
143
+
144
+ add_context! context
145
+
146
+ parsed = {
147
+ :context => context,
148
+ :messageId => message_id,
149
+ :timestamp => datetime_in_iso8601(timestamp)
150
+ }
151
+
152
+ parsed[:userId] = fields[:user_id] if fields[:user_id]
153
+ parsed[:anonymousId] = fields[:anonymous_id] if fields[:anonymous_id]
154
+ parsed[:integrations] = fields[:integrations] if fields[:integrations]
155
+
156
+ # Not in spec, retained for backward compatibility
157
+ parsed[:options] = fields[:options] if fields[:options]
158
+
159
+ parsed
160
+ end
161
+
162
+ def check_user_id!(fields)
163
+ unless fields[:user_id] || fields[:anonymous_id]
164
+ raise ArgumentError, 'Must supply either user_id or anonymous_id'
165
+ end
166
+ end
167
+
168
+ def check_timestamp!(timestamp)
169
+ raise ArgumentError, 'Timestamp must be a Time' unless timestamp.is_a? Time
170
+ end
171
+
172
+ def add_context!(context)
173
+ context[:library] = { :name => 'analytics-ruby', :version => Clearbit::VERSION.to_s }
174
+ end
175
+
176
+ # private: Ensures that a string is non-empty
177
+ #
178
+ # obj - String|Number that must be non-blank
179
+ # name - Name of the validated value
180
+ def check_presence!(obj, name)
181
+ if obj.nil? || (obj.is_a?(String) && obj.empty?)
182
+ raise ArgumentError, "#{name} must be given"
183
+ end
184
+ end
185
+
186
+ def check_is_hash!(obj, name)
187
+ raise ArgumentError, "#{name} must be a Hash" unless obj.is_a? Hash
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,60 @@
1
+ require 'logger'
2
+
3
+ module Clearbit
4
+ class Analytics
5
+ # Wraps an existing logger and adds a prefix to all messages
6
+ class PrefixedLogger
7
+ def initialize(logger, prefix)
8
+ @logger = logger
9
+ @prefix = prefix
10
+ end
11
+
12
+ def debug(msg)
13
+ @logger.debug("#{@prefix} #{msg}")
14
+ end
15
+
16
+ def info(msg)
17
+ @logger.info("#{@prefix} #{msg}")
18
+ end
19
+
20
+ def warn(msg)
21
+ @logger.warn("#{@prefix} #{msg}")
22
+ end
23
+
24
+ def error(msg)
25
+ @logger.error("#{@prefix} #{msg}")
26
+ end
27
+ end
28
+
29
+ module Logging
30
+ class << self
31
+ def logger
32
+ return @logger if @logger
33
+
34
+ base_logger = if defined?(Rails)
35
+ Rails.logger
36
+ else
37
+ logger = Logger.new STDOUT
38
+ logger.progname = 'Clearbit::Analytics'
39
+ logger
40
+ end
41
+ @logger = PrefixedLogger.new(base_logger, '[analytics-ruby]')
42
+ end
43
+
44
+ attr_writer :logger
45
+ end
46
+
47
+ def self.included(base)
48
+ class << base
49
+ def logger
50
+ Logging.logger
51
+ end
52
+ end
53
+ end
54
+
55
+ def logger
56
+ Logging.logger
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,72 @@
1
+ require 'forwardable'
2
+ require 'clearbit/analytics/logging'
3
+
4
+ module Clearbit
5
+ class Analytics
6
+ # A batch of `Message`s to be sent to the API
7
+ class MessageBatch
8
+ class JSONGenerationError < StandardError; end
9
+
10
+ extend Forwardable
11
+ include Clearbit::Analytics::Logging
12
+ include Clearbit::Analytics::Defaults::MessageBatch
13
+
14
+ def initialize(max_message_count)
15
+ @messages = []
16
+ @max_message_count = max_message_count
17
+ @json_size = 0
18
+ end
19
+
20
+ def <<(message)
21
+ begin
22
+ message_json = message.to_json
23
+ rescue StandardError => e
24
+ raise JSONGenerationError, "Serialization error: #{e}"
25
+ end
26
+
27
+ message_json_size = message_json.bytesize
28
+ if message_too_big?(message_json_size)
29
+ logger.error('a message exceeded the maximum allowed size')
30
+ else
31
+ @messages << message
32
+ @json_size += message_json_size + 1 # One byte for the comma
33
+ end
34
+ end
35
+
36
+ def full?
37
+ item_count_exhausted? || size_exhausted?
38
+ end
39
+
40
+ def clear
41
+ @messages.clear
42
+ @json_size = 0
43
+ end
44
+
45
+ def_delegators :@messages, :to_json
46
+ def_delegators :@messages, :empty?
47
+ def_delegators :@messages, :length
48
+
49
+ private
50
+
51
+ def item_count_exhausted?
52
+ @messages.length >= @max_message_count
53
+ end
54
+
55
+ def message_too_big?(message_json_size)
56
+ message_json_size > Defaults::Message::MAX_BYTES
57
+ end
58
+
59
+ # We consider the max size here as just enough to leave room for one more
60
+ # message of the largest size possible. This is a shortcut that allows us
61
+ # to use a native Ruby `Queue` that doesn't allow peeking. The tradeoff
62
+ # here is that we might fit in less messages than possible into a batch.
63
+ #
64
+ # The alternative is to use our own `Queue` implementation that allows
65
+ # peeking, and to consider the next message size when calculating whether
66
+ # the message can be accomodated in this batch.
67
+ def size_exhausted?
68
+ @json_size >= (MAX_BYTES - Defaults::Message::MAX_BYTES)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,134 @@
1
+ require 'clearbit/analytics/defaults'
2
+ require 'clearbit/analytics/utils'
3
+ require 'clearbit/analytics/response'
4
+ require 'clearbit/analytics/logging'
5
+ require 'clearbit/analytics/backoff_policy'
6
+ require 'net/http'
7
+ require 'net/https'
8
+ require 'json'
9
+
10
+ module Clearbit
11
+ class Analytics
12
+ class Request
13
+ include Clearbit::Analytics::Defaults::Request
14
+ include Clearbit::Analytics::Utils
15
+ include Clearbit::Analytics::Logging
16
+
17
+ # public: Creates a new request object to send analytics batch
18
+ #
19
+ def initialize(options = {})
20
+ options[:host] ||= HOST
21
+ options[:port] ||= PORT
22
+ options[:ssl] ||= SSL
23
+ @headers = options[:headers] || HEADERS
24
+ @path = options[:path] || PATH
25
+ @retries = options[:retries] || RETRIES
26
+ @backoff_policy =
27
+ options[:backoff_policy] || Clearbit::Analytics::BackoffPolicy.new
28
+
29
+ http = Net::HTTP.new(options[:host], options[:port])
30
+ http.use_ssl = options[:ssl]
31
+ http.read_timeout = 8
32
+ http.open_timeout = 4
33
+
34
+ @http = http
35
+ end
36
+
37
+ # public: Posts the write key and batch of messages to the API.
38
+ #
39
+ # returns - Response of the status and error if it exists
40
+ def post(write_key, batch)
41
+ logger.debug("Sending request for #{batch.length} items")
42
+
43
+ last_response, exception = retry_with_backoff(@retries) do
44
+ status_code, body = send_request(write_key, batch)
45
+ error = JSON.parse(body)['error']
46
+ should_retry = should_retry_request?(status_code, body)
47
+ logger.debug("Response status code: #{status_code}")
48
+ logger.debug("Response error: #{error}") if error
49
+
50
+ [Response.new(status_code, error), should_retry]
51
+ end
52
+
53
+ if exception
54
+ logger.error(exception.message)
55
+ exception.backtrace.each { |line| logger.error(line) }
56
+ Response.new(-1, exception.to_s)
57
+ else
58
+ last_response
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def should_retry_request?(status_code, body)
65
+ if status_code >= 500
66
+ true # Server error
67
+ elsif status_code == 429
68
+ true # Rate limited
69
+ elsif status_code >= 400
70
+ logger.error(body)
71
+ false # Client error. Do not retry, but log
72
+ else
73
+ false
74
+ end
75
+ end
76
+
77
+ # Takes a block that returns [result, should_retry].
78
+ #
79
+ # Retries upto `retries_remaining` times, if `should_retry` is false or
80
+ # an exception is raised. `@backoff_policy` is used to determine the
81
+ # duration to sleep between attempts
82
+ #
83
+ # Returns [last_result, raised_exception]
84
+ def retry_with_backoff(retries_remaining, &block)
85
+ result, caught_exception = nil
86
+ should_retry = false
87
+
88
+ begin
89
+ result, should_retry = yield
90
+ return [result, nil] unless should_retry
91
+ rescue StandardError => e
92
+ should_retry = true
93
+ caught_exception = e
94
+ end
95
+
96
+ if should_retry && (retries_remaining > 1)
97
+ logger.debug("Retrying request, #{retries_remaining} retries left")
98
+ sleep(@backoff_policy.next_interval.to_f / 1000)
99
+ retry_with_backoff(retries_remaining - 1, &block)
100
+ else
101
+ [result, caught_exception]
102
+ end
103
+ end
104
+
105
+ # Sends a request for the batch, returns [status_code, body]
106
+ def send_request(write_key, batch)
107
+ payload = JSON.generate(
108
+ :sentAt => datetime_in_iso8601(Time.now),
109
+ :batch => batch
110
+ )
111
+ request = Net::HTTP::Post.new(@path, @headers)
112
+ request.basic_auth(write_key, nil)
113
+
114
+ if self.class.stub
115
+ logger.debug "stubbed request to #{@path}: " \
116
+ "write key = #{write_key}, batch = JSON.generate(#{batch})"
117
+
118
+ [200, '{}']
119
+ else
120
+ response = @http.request(request, payload)
121
+ [response.code.to_i, response.body]
122
+ end
123
+ end
124
+
125
+ class << self
126
+ attr_writer :stub
127
+
128
+ def stub
129
+ @stub || ENV['STUB']
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,15 @@
1
+ module Clearbit
2
+ class Analytics
3
+ class Response
4
+ attr_reader :status, :error
5
+
6
+ # public: Simple class to wrap responses from the API
7
+ #
8
+ #
9
+ def initialize(status = 200, error = nil)
10
+ @status = status
11
+ @error = error
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,91 @@
1
+ require 'securerandom'
2
+
3
+ module Clearbit
4
+ class Analytics
5
+ module Utils
6
+ extend self
7
+
8
+ # public: Return a new hash with keys converted from strings to symbols
9
+ #
10
+ def symbolize_keys(hash)
11
+ hash.each_with_object({}) do |(k, v), memo|
12
+ memo[k.to_sym] = v
13
+ end
14
+ end
15
+
16
+ # public: Convert hash keys from strings to symbols in place
17
+ #
18
+ def symbolize_keys!(hash)
19
+ hash.replace symbolize_keys hash
20
+ end
21
+
22
+ # public: Return a new hash with keys as strings
23
+ #
24
+ def stringify_keys(hash)
25
+ hash.each_with_object({}) do |(k, v), memo|
26
+ memo[k.to_s] = v
27
+ end
28
+ end
29
+
30
+ # public: Returns a new hash with all the date values in the into iso8601
31
+ # strings
32
+ #
33
+ def isoify_dates(hash)
34
+ hash.each_with_object({}) do |(k, v), memo|
35
+ memo[k] = datetime_in_iso8601(v)
36
+ end
37
+ end
38
+
39
+ # public: Converts all the date values in the into iso8601 strings in place
40
+ #
41
+ def isoify_dates!(hash)
42
+ hash.replace isoify_dates hash
43
+ end
44
+
45
+ # public: Returns a uid string
46
+ #
47
+ def uid
48
+ arr = SecureRandom.random_bytes(16).unpack('NnnnnN')
49
+ arr[2] = (arr[2] & 0x0fff) | 0x4000
50
+ arr[3] = (arr[3] & 0x3fff) | 0x8000
51
+ '%08x-%04x-%04x-%04x-%04x%08x' % arr
52
+ end
53
+
54
+ def datetime_in_iso8601(datetime)
55
+ case datetime
56
+ when Time
57
+ time_in_iso8601 datetime
58
+ when DateTime
59
+ time_in_iso8601 datetime.to_time
60
+ when Date
61
+ date_in_iso8601 datetime
62
+ else
63
+ datetime
64
+ end
65
+ end
66
+
67
+ def time_in_iso8601(time, fraction_digits = 3)
68
+ fraction = if fraction_digits > 0
69
+ ('.%06i' % time.usec)[0, fraction_digits + 1]
70
+ end
71
+
72
+ "#{time.strftime('%Y-%m-%dT%H:%M:%S')}#{fraction}#{formatted_offset(time, true, 'Z')}"
73
+ end
74
+
75
+ def date_in_iso8601(date)
76
+ date.strftime('%F')
77
+ end
78
+
79
+ def formatted_offset(time, colon = true, alternate_utc_string = nil)
80
+ time.utc? && alternate_utc_string || seconds_to_utc_offset(time.utc_offset, colon)
81
+ end
82
+
83
+ def seconds_to_utc_offset(seconds, colon = true)
84
+ (colon ? UTC_OFFSET_WITH_COLON : UTC_OFFSET_WITHOUT_COLON) % [(seconds < 0 ? '-' : '+'), (seconds.abs / 3600), ((seconds.abs % 3600) / 60)]
85
+ end
86
+
87
+ UTC_OFFSET_WITH_COLON = '%s%02d:%02d'
88
+ UTC_OFFSET_WITHOUT_COLON = UTC_OFFSET_WITH_COLON.sub(':', '')
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,66 @@
1
+ require 'clearbit/analytics/defaults'
2
+ require 'clearbit/analytics/message_batch'
3
+ require 'clearbit/analytics/request'
4
+ require 'clearbit/analytics/utils'
5
+
6
+ module Clearbit
7
+ class Analytics
8
+ class Worker
9
+ include Clearbit::Analytics::Utils
10
+ include Clearbit::Analytics::Defaults
11
+ include Clearbit::Analytics::Logging
12
+
13
+ # public: Creates a new worker
14
+ #
15
+ # The worker continuously takes messages off the queue
16
+ # and makes requests to the segment.io api
17
+ #
18
+ # queue - Queue synchronized between client and worker
19
+ # write_key - String of the project's Write key
20
+ # options - Hash of worker options
21
+ # batch_size - Fixnum of how many items to send in a batch
22
+ # on_error - Proc of what to do on an error
23
+ #
24
+ def initialize(queue, write_key, options = {})
25
+ symbolize_keys! options
26
+ @queue = queue
27
+ @write_key = write_key
28
+ @on_error = options[:on_error] || proc { |status, error| }
29
+ batch_size = options[:batch_size] || Defaults::MessageBatch::MAX_SIZE
30
+ @batch = MessageBatch.new(batch_size)
31
+ @lock = Mutex.new
32
+ end
33
+
34
+ # public: Continuously runs the loop to check for new events
35
+ #
36
+ def run
37
+ until Thread.current[:should_exit]
38
+ return if @queue.empty?
39
+
40
+ @lock.synchronize do
41
+ consume_message_from_queue! until @batch.full? || @queue.empty?
42
+ end
43
+
44
+ res = Request.new.post @write_key, @batch
45
+ @on_error.call(res.status, res.error) unless res.status == 200
46
+
47
+ @lock.synchronize { @batch.clear }
48
+ end
49
+ end
50
+
51
+ # public: Check whether we have outstanding requests.
52
+ #
53
+ def is_requesting?
54
+ @lock.synchronize { !@batch.empty? }
55
+ end
56
+
57
+ private
58
+
59
+ def consume_message_from_queue!
60
+ @batch << @queue.pop
61
+ rescue MessageBatch::JSONGenerationError => e
62
+ @on_error.call(-1, e.to_s)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,14 @@
1
+ module Clearbit
2
+ class Audiences < Base
3
+ endpoint 'https://audiences.clearbit.com'
4
+ path '/v1/audiences'
5
+
6
+ def self.add_email(values = {})
7
+ post('email', values)
8
+ end
9
+
10
+ def self.add_domain(values = {})
11
+ post('domain', values)
12
+ end
13
+ end
14
+ end
@@ -1,3 +1,3 @@
1
1
  module Clearbit
2
- VERSION = '0.2.8'
2
+ VERSION = '0.3.0'
3
3
  end
@@ -0,0 +1,25 @@
1
+ require 'spec_helper'
2
+ require 'base64'
3
+
4
+ describe Clearbit::Analytics do
5
+ before do |example|
6
+ Clearbit.key = 'clearbit_key'
7
+ end
8
+
9
+ it 'sends an identify request to Clearbit X' do
10
+ basic_auth = Base64.encode64('clearbit_key:').strip
11
+ x_stub = stub_request(:post, 'https://x.clearbit.com/v1/import').
12
+ with(
13
+ headers: { 'Authorization' => "Basic #{basic_auth}" }
14
+ ).to_return(status: 200, body: {success: true}.to_json)
15
+
16
+ Clearbit::Analytics.identify(
17
+ user_id: '123',
18
+ traits: {
19
+ email: 'david@clearbit.com',
20
+ },
21
+ )
22
+
23
+ expect(x_stub).to have_been_requested
24
+ end
25
+ end
@@ -6,8 +6,8 @@ describe Clearbit::Prospector do
6
6
  end
7
7
 
8
8
  context 'Prospector API' do
9
- it 'should call out to the Prospector API' do
10
- body = []
9
+ it 'calls the Prospector API' do
10
+ body = { page: 1, page_size: 5, total: 723, results: [] }
11
11
 
12
12
  stub_request(:get, "https://prospector.clearbit.com/v1/people/search?domain=stripe.com").
13
13
  with(:headers => {'Authorization'=>'Bearer clearbit_key'}).
@@ -15,5 +15,15 @@ describe Clearbit::Prospector do
15
15
 
16
16
  Clearbit::Prospector.search(domain: 'stripe.com')
17
17
  end
18
+
19
+ it 'can page through records' do
20
+ body = { page: 2, page_size: 10, total: 12, results: [] }
21
+
22
+ stub_request(:get, "https://prospector.clearbit.com/v1/people/search?domain=stripe.com&page=2&page_size=10").
23
+ with(:headers => {'Authorization'=>'Bearer clearbit_key'}).
24
+ to_return(:status => 200, :body => body.to_json, headers: {'Content-Type' => 'application/json'})
25
+
26
+ Clearbit::Prospector.search(domain: 'stripe.com', page: 2, page_size: 10)
27
+ end
18
28
  end
19
29
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clearbit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.8
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex MacCaw
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2017-10-24 00:00:00.000000000 Z
11
+ date: 2019-08-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -129,6 +129,7 @@ executables: []
129
129
  extensions: []
130
130
  extra_rdoc_files: []
131
131
  files:
132
+ - ".github/CODEOWNERS"
132
133
  - ".gitignore"
133
134
  - Gemfile
134
135
  - LICENSE
@@ -148,6 +149,19 @@ files:
148
149
  - examples/version.rb
149
150
  - examples/watchlist.rb
150
151
  - lib/clearbit.rb
152
+ - lib/clearbit/analytics.rb
153
+ - lib/clearbit/analytics/LICENSE
154
+ - lib/clearbit/analytics/backoff_policy.rb
155
+ - lib/clearbit/analytics/client.rb
156
+ - lib/clearbit/analytics/defaults.rb
157
+ - lib/clearbit/analytics/field_parser.rb
158
+ - lib/clearbit/analytics/logging.rb
159
+ - lib/clearbit/analytics/message_batch.rb
160
+ - lib/clearbit/analytics/request.rb
161
+ - lib/clearbit/analytics/response.rb
162
+ - lib/clearbit/analytics/utils.rb
163
+ - lib/clearbit/analytics/worker.rb
164
+ - lib/clearbit/audiences.rb
151
165
  - lib/clearbit/autocomplete.rb
152
166
  - lib/clearbit/base.rb
153
167
  - lib/clearbit/discovery.rb
@@ -168,6 +182,7 @@ files:
168
182
  - lib/clearbit/version.rb
169
183
  - lib/clearbit/watchlist.rb
170
184
  - lib/clearbit/webhook.rb
185
+ - spec/lib/clearbit/analytics_spec.rb
171
186
  - spec/lib/clearbit/discovery_spec.rb
172
187
  - spec/lib/clearbit/enrichment_spec.rb
173
188
  - spec/lib/clearbit/logo_spec.rb
@@ -195,11 +210,12 @@ required_rubygems_version: !ruby/object:Gem::Requirement
195
210
  version: '0'
196
211
  requirements: []
197
212
  rubyforge_project:
198
- rubygems_version: 2.5.1
213
+ rubygems_version: 2.7.3
199
214
  signing_key:
200
215
  specification_version: 4
201
216
  summary: API client for clearbit.com
202
217
  test_files:
218
+ - spec/lib/clearbit/analytics_spec.rb
203
219
  - spec/lib/clearbit/discovery_spec.rb
204
220
  - spec/lib/clearbit/enrichment_spec.rb
205
221
  - spec/lib/clearbit/logo_spec.rb