companyenrich 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b29a5f9bca5c9a4988c19c84b4c879d7ff44dcbffdb2c29bfc495e27740a0960
4
+ data.tar.gz: 628e62829990c88d4fbf9c725587232db9418a5bd7c0edc345dfcc74d397b27f
5
+ SHA512:
6
+ metadata.gz: dc6253692f6c67c9c843a14e376bfc5d25da701545fedb4406e10ac17cbd80da57bcd3ab6edf914e05a1ea8893addfc8d9dbc39c81c7a37e6456b764b003fb6e
7
+ data.tar.gz: c133eff3296f9938f5ad1fc5da81ba3c5a11a48add44f3ce240b33289b3468d2e93bb0ef310db3fd79e375bfa66b78e7e98eb28189fcf7a68d282a436fcb5256
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 STDIO Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,327 @@
1
+ # CompanyEnrich Ruby SDK
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/companyenrich)](https://rubygems.org/gems/companyenrich)
4
+
5
+ The official Ruby client for the [CompanyEnrich API](https://companyenrich.com): company and people enrichment, filter, semantic and lookalike search, work emails, and bulk jobs.
6
+
7
+ It requires Ruby 3.1 or newer and has no runtime dependencies.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ gem install companyenrich
13
+ ```
14
+
15
+ Or add it to your `Gemfile`:
16
+
17
+ ```
18
+ gem "companyenrich"
19
+ ```
20
+
21
+ ## Quick start
22
+
23
+ Create an API key in your [dashboard](https://companyenrich.com/dashboard) and keep it secret.
24
+
25
+ ```ruby
26
+ require "companyenrich"
27
+
28
+ # Reads COMPANYENRICH_API_KEY from the environment, or pass api_key: "your-api-key".
29
+ client = CompanyEnrich::Client.new
30
+
31
+ company = client.companies.enrich("stripe.com")
32
+ puts company["name"], company["employees"]
33
+ ```
34
+
35
+ Responses are plain hashes with the API's field names as string keys. Optional fields can be absent or `nil`.
36
+
37
+ Method arguments are `snake_case` keywords. Hashes you pass, such as `filter`, use the API's own field names.
38
+
39
+ ## Companies
40
+
41
+ ```ruby
42
+ # Add paid data to a lookup. Each expansion costs extra credits.
43
+ detailed = client.companies.enrich("stripe.com", expand: %w[technologies workforce])
44
+
45
+ # Up to 50 domains at once. Check each item's status.
46
+ batch = client.companies.enrich_batch(["stripe.com", "adyen.com"])
47
+ batch["items"].each do |item|
48
+ puts item["domain"], item.dig("company", "name") if item["status"] == "found"
49
+ end
50
+
51
+ # Filter search
52
+ page = client.companies.search(
53
+ filter: { country: "US", employees: ["51-200", "201-500"], categories: "saas" },
54
+ page_size: 25
55
+ )
56
+ puts page["items"].size, page["nextCursor"]
57
+
58
+ # Free count of the same query
59
+ total = client.companies.count(filter: { country: "US" })
60
+ puts total["count"]
61
+ ```
62
+
63
+ ## People
64
+
65
+ ```ruby
66
+ results = client.people.search(
67
+ filter: { domain: "stripe.com", seniority: %w[c-suite founder] },
68
+ page_size: 10
69
+ )
70
+
71
+ person = client.people.enrich("jane@stripe.com")
72
+
73
+ lookup = client.people.find_email(person["id"], domain: "stripe.com")
74
+ puts lookup["email"], lookup["certainty"] if lookup["status"] == "found"
75
+ ```
76
+
77
+ ## Semantic and lookalike search
78
+
79
+ Semantic search finds companies or people from a plain-language description. Lookalike search finds companies similar to ones you already know. A company search can use either mode but not both, and either can be combined with `filter`. `search_all` accepts the same arguments as `search`.
80
+
81
+ | Search | Credits per result |
82
+ | --- | --- |
83
+ | Companies by filters | 1 |
84
+ | Companies by description | 2 |
85
+ | Lookalike companies | 5 |
86
+ | People, in any mode | 2 |
87
+
88
+ Paid expansions cost extra, and an empty result costs one result's base price. To try a query for free, pass the same arguments to `companies.preview` or `people.preview`, which need a plan with previews.
89
+
90
+ ### Companies by description
91
+
92
+ By default, the API reads attributes such as location and size from your query and applies them. `interpretation` shows what it inferred.
93
+
94
+ ```ruby
95
+ results = client.companies.search(
96
+ semantic: { query: "saas companies in Turkey" },
97
+ page_size: 25
98
+ )
99
+
100
+ results["items"].each do |company|
101
+ puts company["domain"], company["description"]
102
+ end
103
+
104
+ p results.dig("interpretation", "filter") # Inferred filters, such as the country
105
+ p results.dig("interpretation", "boost") # Inferred ranking boosts, such as the category
106
+ ```
107
+
108
+ Filters you pass take priority over inferred values of the same kind. Set `autoFilter: false` to turn inference off, or set `weight` (0 to 1) to control how much semantic similarity affects ranking.
109
+
110
+ ```ruby
111
+ filtered = client.companies.search(
112
+ semantic: { query: "AI tools for hospitals", autoFilter: false },
113
+ filter: { country: "US", employees: ["51-200", "201-500"] },
114
+ page_size: 25
115
+ )
116
+ ```
117
+
118
+ ### Lookalike companies
119
+
120
+ Pass 1 to 10 seed domains. Use `minScore` (0 to 1) to require a minimum similarity.
121
+
122
+ ```ruby
123
+ similar = client.companies.search(
124
+ lookalike: { domains: ["stripe.com", "adyen.com"] },
125
+ filter: { country: "US", employees: ["51-200", "201-500"] },
126
+ page_size: 25
127
+ )
128
+
129
+ scores = similar["scores"] || {} # Scores are not probabilities
130
+ similar["items"].each do |company|
131
+ puts company["domain"], scores[company["id"]]
132
+ end
133
+ ```
134
+
135
+ Keep results in the order returned rather than sorting them by score.
136
+
137
+ ### People by description
138
+
139
+ The API turns your query into person filters, such as role, seniority and country, plus a description of their current companies. `interpretation` shows both. A query it can't infer any filters from is rejected with an error, so include something concrete like a role or location.
140
+
141
+ ```ruby
142
+ people = client.people.search(
143
+ semantic: { query: "marketing leaders at Series B fintech startups in London" },
144
+ page_size: 25
145
+ )
146
+
147
+ people["items"].each do |person|
148
+ puts person["name"], person["position"]
149
+ end
150
+
151
+ p people["interpretation"] # The inferred filters and company description
152
+ ```
153
+
154
+ ### People at matching companies
155
+
156
+ Use `company` to find people by the kind of company they work at. These searches cover people at the 1,000 best-matching companies.
157
+
158
+ ```ruby
159
+ # Executives at companies matching a description
160
+ executives = client.people.search(
161
+ filter: { seniority: %w[c-suite vp] },
162
+ company: { semantic: { query: "AI tools for hospitals" } },
163
+ page_size: 25
164
+ )
165
+
166
+ # Executives at companies similar to your best customers
167
+ similar_teams = client.people.search(
168
+ filter: { seniority: %w[c-suite vp] },
169
+ company: { lookalike: { domains: ["stripe.com", "adyen.com"] } },
170
+ page_size: 25
171
+ )
172
+ ```
173
+
174
+ ## Pagination
175
+
176
+ `search` returns one page. The `*_all` methods take a block, or return a lazy `Enumerator` that requests pages as you consume them.
177
+
178
+ ```ruby
179
+ client.companies.search_all(filter: { country: "TR" }, page_size: 100) do |company|
180
+ puts company["domain"]
181
+ end
182
+
183
+ first_ten = client.people.search_all(filter: { domain: "stripe.com" }, page_size: 10).first(10)
184
+ ```
185
+
186
+ These are available as `companies.search_all`, `people.search_all`, `batches.list_all`, and `geo.search_countries_all`, `geo.search_states_all` and `geo.search_cities_all`.
187
+
188
+ Search credits are charged for every result on a fetched page, even if you stop iterating partway through it. Set `page_size` close to the number of results you plan to use.
189
+
190
+ ## Bulk jobs
191
+
192
+ Bulk enrichment and search exports run in the background.
193
+
194
+ ```ruby
195
+ job = client.companies.bulk_enrichment.create(["stripe.com", "adyen.com"])
196
+
197
+ client.batches.wait_for_completion(job["id"]) do |batch|
198
+ puts "#{batch['progress']['succeeded']}/#{batch['progress']['total']}"
199
+ end
200
+
201
+ client.companies.bulk_enrichment.stream(job["id"]) do |record|
202
+ puts record["domain"], record.dig("company", "name") if record["success"]
203
+ end
204
+ ```
205
+
206
+ `companies.bulk_enrichment`, `companies.search_export` and `people.search_export` share the same methods:
207
+
208
+ | Method | Returns |
209
+ | --- | --- |
210
+ | `create(...)` | The new batch's status |
211
+ | `stream(id)` | Each record in turn, best for large jobs |
212
+ | `results(id)` | One JSON document with every record |
213
+ | `csv(id)` | The CSV as a string |
214
+ | `csv_chunks(id)` | The CSV as raw bytes, for writing to a file |
215
+
216
+ ## Errors
217
+
218
+ Failed requests raise a subclass of `CompanyEnrich::APIError`, which carries `status`, `title`, `detail`, `errors` (field-level validation messages) and `trace_id` (include it when contacting support).
219
+
220
+ ```ruby
221
+ begin
222
+ client.companies.enrich("example.invalid")
223
+ rescue CompanyEnrich::NotFoundError
224
+ puts "No company found"
225
+ rescue CompanyEnrich::PaymentRequiredError
226
+ puts "Out of credits, or not included in this plan"
227
+ rescue CompanyEnrich::APIError => e
228
+ puts e.status, e.detail, e.trace_id
229
+ end
230
+ ```
231
+
232
+ | Status | Class |
233
+ | --- | --- |
234
+ | 400 | `BadRequestError` |
235
+ | 401 | `AuthenticationError` |
236
+ | 402 | `PaymentRequiredError` |
237
+ | 403 | `PermissionDeniedError` |
238
+ | 404 | `NotFoundError` |
239
+ | 422 | `ValidationError` |
240
+ | 429 | `RateLimitError` |
241
+ | 5xx | `ServerError` |
242
+ | Other | `APIError` |
243
+
244
+ `APIConnectionError` and `APITimeoutError` cover requests that never received a response or were cut off. Everything above extends `CompanyEnrich::Error`.
245
+
246
+ ## Retries and timeouts
247
+
248
+ Failed requests are retried up to twice with exponential backoff.
249
+
250
+ | Failure | GET and free POST endpoints | Other POST endpoints |
251
+ | --- | --- | --- |
252
+ | 429 rate limit | Retried | Retried |
253
+ | 408, 5xx, connection error, timeout | Retried | Not retried |
254
+
255
+ POST requests that consume credits or start a job are not repeated after an ambiguous failure, because the first attempt may already have been charged or created a batch. A download that fails partway through is never restarted, so you won't receive duplicate records.
256
+
257
+ ## Configuration
258
+
259
+ ```ruby
260
+ configured = CompanyEnrich::Client.new(api_key: "your-api-key", timeout: 30, max_retries: 4)
261
+
262
+ # Every method accepts per-request options.
263
+ configured.me(options: { timeout: 5, max_retries: 0 })
264
+ ```
265
+
266
+ | Option | Default | Description |
267
+ | --- | --- | --- |
268
+ | `api_key` | `COMPANYENRICH_API_KEY` | Your API key |
269
+ | `base_url` | `https://api.companyenrich.com` | API origin, for proxies |
270
+ | `timeout` | `120` | Timeout in seconds. `nil` disables it |
271
+ | `max_retries` | `2` | Retries after the first attempt |
272
+ | `default_headers` | none | Headers added to every request |
273
+
274
+ Each request uses its own connection, so one client can be shared across threads.
275
+
276
+ ## API reference
277
+
278
+ | SDK method | Endpoint |
279
+ | --- | --- |
280
+ | `companies.get(id)` | `GET /v2/companies/{companyId}` |
281
+ | `companies.enrich(domain)` | `GET /v2/companies/enrich` |
282
+ | `companies.enrich_by_properties(...)` | `POST /v2/companies/enrich` |
283
+ | `companies.enrich_batch(domains)` | `POST /v2/companies/enrich/batch` |
284
+ | `companies.autocomplete(query)` | `GET /v2/companies/autocomplete` |
285
+ | `companies.workforce(...)` | `GET /v2/companies/workforce` |
286
+ | `companies.search(...)` | `POST /v2/companies/search` |
287
+ | `companies.count(...)` | `POST /v2/companies/search/count` |
288
+ | `companies.preview(...)` | `POST /v2/companies/search/preview` |
289
+ | `companies.bulk_enrichment.*` | `/v2/companies/enrich/bulk` |
290
+ | `companies.search_export.*` | `/v2/companies/search/export` |
291
+ | `people.get(id)` | `GET /v2/people/{personId}` |
292
+ | `people.enrich(email)` | `POST /v2/people/enrich` |
293
+ | `people.find_email(id)` | `GET /v2/people/{personId}/email` |
294
+ | `people.search(...)` | `POST /v2/people/search` |
295
+ | `people.count(...)` | `POST /v2/people/search/count` |
296
+ | `people.preview(...)` | `POST /v2/people/search/preview` |
297
+ | `people.search_export.*` | `/v2/people/search/export` |
298
+ | `batches.list(...)` | `GET /v2/batches` |
299
+ | `batches.get(id)` | `GET /v2/batches/{id}` |
300
+ | `batches.cancel(id)` | `POST /v2/batches/{id}/cancel` |
301
+ | `batches.raw_results(id)` | `GET /v2/batches/{id}/results` |
302
+ | `batches.wait_for_completion(id)` | Polls `GET /v2/batches/{id}` |
303
+ | `geo.regions` | `GET /v2/geo/regions` |
304
+ | `geo.country(code)` | `GET /v2/geo/countries/{countryCode}` |
305
+ | `geo.search_countries(...)` | `POST /v2/geo/countries` |
306
+ | `geo.search_states(...)` | `POST /v2/geo/states` |
307
+ | `geo.search_cities(...)` | `POST /v2/geo/cities` |
308
+ | `industries.list` | `GET /v2/industries` |
309
+ | `keywords.autocomplete(query)` | `GET /v2/keywords/autocomplete` |
310
+ | `technologies.autocomplete(query)` | `GET /v2/technologies/autocomplete` |
311
+ | `positions.autocomplete(query)` | `GET /v2/positions/autocomplete` |
312
+ | `me` | `GET /v2/me` |
313
+
314
+ See the [API documentation](https://api.companyenrich.com/openapi/v2.json) for parameters and credit costs.
315
+
316
+ ## Development
317
+
318
+ ```sh
319
+ bundle install
320
+ bundle exec rake
321
+ ```
322
+
323
+ To release, bump `VERSION` in `lib/companyenrich/version.rb` and push to `main`. The publish workflow runs the checks and publishes that version to RubyGems if it is new.
324
+
325
+ ## License
326
+
327
+ MIT
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "http_client"
4
+ require_relative "resources/batches"
5
+ require_relative "resources/companies"
6
+ require_relative "resources/geo"
7
+ require_relative "resources/people"
8
+ require_relative "resources/reference"
9
+
10
+ module CompanyEnrich
11
+ # Client for the CompanyEnrich API.
12
+ class Client
13
+ attr_reader :companies, :people, :batches, :geo, :industries, :keywords, :technologies, :positions
14
+
15
+ # @param api_key [String, nil] defaults to the +COMPANYENRICH_API_KEY+ environment variable
16
+ # @param base_url [String] API origin, for proxies
17
+ # @param timeout [Numeric, nil] timeout in seconds for each attempt. +nil+ disables it.
18
+ # @param max_retries [Integer] how many times to retry a failed request
19
+ # @param default_headers [Hash, nil] headers sent with every request
20
+ def initialize(api_key: nil, base_url: HTTPClient::DEFAULT_BASE_URL, timeout: HTTPClient::DEFAULT_TIMEOUT,
21
+ max_retries: HTTPClient::DEFAULT_MAX_RETRIES, default_headers: nil)
22
+ @http = HTTPClient.new(
23
+ api_key: api_key, base_url: base_url, timeout: timeout,
24
+ max_retries: max_retries, default_headers: default_headers
25
+ )
26
+ @companies = Resources::Companies.new(@http)
27
+ @people = Resources::People.new(@http)
28
+ @batches = Resources::Batches.new(@http)
29
+ @geo = Resources::Geo.new(@http)
30
+ @industries = Resources::Industries.new(@http)
31
+ @keywords = Resources::Autocomplete.new(@http, "/v2/keywords/autocomplete")
32
+ @technologies = Resources::Autocomplete.new(@http, "/v2/technologies/autocomplete")
33
+ @positions = Resources::Autocomplete.new(@http, "/v2/positions/autocomplete")
34
+ end
35
+
36
+ # Returns the authenticated user, including credit balance and plan capabilities. Free.
37
+ def me(options: nil)
38
+ @http.request("GET", "/v2/me", options: options)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompanyEnrich
4
+ # Base class for every error raised by this SDK.
5
+ class Error < StandardError; end
6
+
7
+ # The request never produced a response, for example because of a DNS or socket failure.
8
+ class APIConnectionError < Error; end
9
+
10
+ # The request, or a +batches.wait_for_completion+ call, ran out of time.
11
+ class APITimeoutError < Error; end
12
+
13
+ # The API responded with a non-2xx status.
14
+ class APIError < Error
15
+ # @return [Integer] the HTTP status
16
+ attr_reader :status
17
+ # @return [Hash{String => String}] response headers, with lowercase names
18
+ attr_reader :headers
19
+ # @return [Object] the parsed response body, or the raw text if it was not JSON
20
+ attr_reader :body
21
+ # @return [Hash{String => Array<String>}, nil] field-level validation messages
22
+ attr_reader :errors
23
+ # @return [String, nil] value of the +x-trace-id+ header. Include it in support requests.
24
+ attr_reader :trace_id
25
+ attr_reader :type, :title, :detail, :instance
26
+
27
+ def initialize(status:, headers:, body:)
28
+ problem = body.is_a?(Hash) ? body : {}
29
+ super(self.class.format_message(status, problem))
30
+ @status = status
31
+ @headers = headers
32
+ @body = body
33
+ @type = problem["type"]
34
+ @title = problem["title"]
35
+ @detail = problem["detail"]
36
+ @instance = problem["instance"]
37
+ @errors = problem["errors"]
38
+ @trace_id = headers["x-trace-id"]
39
+ end
40
+
41
+ def self.format_message(status, problem)
42
+ summary = [problem["title"], problem["detail"]].compact.reject(&:empty?).join(": ")
43
+ fields = (problem["errors"] || {}).map { |field, messages| "#{field}: #{messages.join(", ")}" }.join("; ")
44
+ [status.to_s, summary, fields.empty? ? nil : "(#{fields})"].compact.reject(&:empty?).join(" ")
45
+ end
46
+ end
47
+
48
+ # +400+: the request was malformed.
49
+ class BadRequestError < APIError; end
50
+
51
+ # +401+: the API key is missing or invalid.
52
+ class AuthenticationError < APIError; end
53
+
54
+ # +402+: the account is out of credits or its plan does not include this request.
55
+ class PaymentRequiredError < APIError; end
56
+
57
+ # +403+: the API key may not perform this request.
58
+ class PermissionDeniedError < APIError; end
59
+
60
+ # +404+: the company, person, or batch does not exist.
61
+ class NotFoundError < APIError; end
62
+
63
+ # +422+: the request failed validation. See {APIError#errors}.
64
+ class ValidationError < APIError; end
65
+
66
+ # +429+: a rate limit was exceeded and automatic retries did not resolve it.
67
+ class RateLimitError < APIError; end
68
+
69
+ # +5xx+: the API failed to process the request.
70
+ class ServerError < APIError; end
71
+
72
+ ERRORS_BY_STATUS = {
73
+ 400 => BadRequestError,
74
+ 401 => AuthenticationError,
75
+ 402 => PaymentRequiredError,
76
+ 403 => PermissionDeniedError,
77
+ 404 => NotFoundError,
78
+ 422 => ValidationError,
79
+ 429 => RateLimitError
80
+ }.freeze
81
+ private_constant :ERRORS_BY_STATUS
82
+
83
+ # @api private
84
+ def self.api_error(status:, headers:, body:)
85
+ fallback = status >= 500 ? ServerError : APIError
86
+ ERRORS_BY_STATUS.fetch(status, fallback).new(status: status, headers: headers, body: body)
87
+ end
88
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "time"
7
+ require "uri"
8
+ require "zlib"
9
+
10
+ require_relative "errors"
11
+ require_relative "ndjson_decoder"
12
+ require_relative "version"
13
+
14
+ module CompanyEnrich
15
+ # Sends requests, applies the retry policy, and maps failures to SDK errors.
16
+ # @api private
17
+ class HTTPClient
18
+ DEFAULT_BASE_URL = "https://api.companyenrich.com"
19
+ DEFAULT_TIMEOUT = 120
20
+ DEFAULT_MAX_RETRIES = 2
21
+
22
+ BASE_BACKOFF = 0.5
23
+ MAX_BACKOFF = 8.0
24
+ MAX_SERVER_REQUESTED_WAIT = 60.0
25
+ RETRYABLE_STATUSES = [408, 500, 502, 503, 504].freeze
26
+ TIMESPAN = /\A(\d+):(\d{2}):(\d{2}(?:\.\d+)?)\z/
27
+
28
+ TIMEOUT_ERRORS = [Timeout::Error, Errno::ETIMEDOUT].freeze
29
+ TRANSPORT_ERRORS = [
30
+ *TIMEOUT_ERRORS, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError,
31
+ Net::ProtocolError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Zlib::Error
32
+ ].freeze
33
+
34
+ def initialize(api_key:, base_url:, timeout:, max_retries:, default_headers:)
35
+ @api_key = api_key || ENV.fetch("COMPANYENRICH_API_KEY", nil)
36
+ if @api_key.nil? || @api_key.empty?
37
+ raise Error, "Missing API key. Pass `api_key:` or set the COMPANYENRICH_API_KEY environment variable."
38
+ end
39
+
40
+ @base_uri = URI.parse(base_url.sub(%r{/+\z}, ""))
41
+ @timeout = timeout
42
+ @max_retries = max_retries
43
+ @default_headers = default_headers
44
+ end
45
+
46
+ # Keeps the API key out of logs and error messages.
47
+ def inspect
48
+ "#<#{self.class} base_url=#{@base_uri}>"
49
+ end
50
+
51
+ # Sends a request and parses the JSON response.
52
+ def request(method, path, query: nil, body: nil, retry_safe: nil, options: nil)
53
+ execute(method, path, query, body, retry_safe, options, accept: "application/json") do |response|
54
+ parse_json(response.read_body, response.code.to_i)
55
+ end
56
+ end
57
+
58
+ # Sends a GET request and yields the response body as it arrives.
59
+ def stream_chunks(path, query: nil, options: nil, &block)
60
+ execute("GET", path, query, nil, nil, options, accept: "*/*", stream: true) do |response|
61
+ read_chunks(response, &block)
62
+ end
63
+ end
64
+
65
+ # Sends a GET request to an NDJSON endpoint and yields one parsed record per line.
66
+ def stream_ndjson(path, query: nil, options: nil)
67
+ decoder = NdjsonDecoder.new
68
+ stream_chunks(path, query: query, options: options) do |chunk|
69
+ decoder.feed(chunk).each { |record| yield record }
70
+ end
71
+ decoder.flush.each { |record| yield record }
72
+ end
73
+
74
+ def pause(seconds)
75
+ sleep(seconds)
76
+ end
77
+
78
+ private
79
+
80
+ def execute(method, path, query, body, retry_safe, options, accept:, stream: false)
81
+ options ||= {}
82
+ max_retries = options.fetch(:max_retries, @max_retries)
83
+ safe = retry_safe.nil? ? method == "GET" : retry_safe
84
+ attempt = 0
85
+ streaming = false
86
+
87
+ begin
88
+ connect(options.fetch(:timeout, @timeout)) do |http|
89
+ http.request(build_request(method, path, query, body, options, accept)) do |response|
90
+ raise_for_status(response)
91
+ # Once records have been handed over, repeating the download would duplicate them.
92
+ streaming = stream
93
+ return yield(response)
94
+ end
95
+ end
96
+ rescue APIError, *TRANSPORT_ERRORS => e
97
+ raise if streaming
98
+
99
+ failure = normalize_failure(e)
100
+ delay = retry_delay(failure, attempt, safe)
101
+ raise failure if delay.nil? || attempt >= max_retries
102
+
103
+ pause(delay)
104
+ attempt += 1
105
+ retry
106
+ end
107
+ end
108
+
109
+ def connect(timeout, &)
110
+ Net::HTTP.start(
111
+ @base_uri.host, @base_uri.port,
112
+ use_ssl: @base_uri.scheme == "https",
113
+ open_timeout: timeout, read_timeout: timeout, write_timeout: timeout,
114
+ # Retries follow this SDK's policy only, so a request is never repeated behind its back.
115
+ max_retries: 0,
116
+ # By default Net::HTTP accepts a truncated body as complete, which would silently lose records.
117
+ ignore_eof: false,
118
+ &
119
+ )
120
+ end
121
+
122
+ def build_request(method, path, query, body, options, accept)
123
+ request_class = method == "POST" ? Net::HTTP::Post : Net::HTTP::Get
124
+ request = request_class.new(request_target(path, query), request_headers(accept, body, options))
125
+ if body
126
+ request.body = JSON.generate(body)
127
+ elsif method == "POST"
128
+ request["Content-Length"] = "0"
129
+ # Net::HTTP would otherwise label an empty POST as a submitted form.
130
+ request["Content-Type"] = "application/json"
131
+ end
132
+ request
133
+ end
134
+
135
+ def request_target(path, query)
136
+ target = "#{@base_uri.path}#{path}"
137
+ pairs = (query || {}).flat_map do |key, value|
138
+ next [] if value.nil?
139
+
140
+ Array(value).map { |item| [key.to_s, item.to_s] }
141
+ end
142
+ pairs.empty? ? target : "#{target}?#{URI.encode_www_form(pairs)}"
143
+ end
144
+
145
+ def request_headers(accept, body, options)
146
+ headers = {
147
+ "accept" => accept,
148
+ "authorization" => "Bearer #{@api_key}",
149
+ "user-agent" => "companyenrich-ruby/#{VERSION}"
150
+ }
151
+ headers["content-type"] = "application/json" if body
152
+ [@default_headers, options[:headers]].compact.each do |extra|
153
+ extra.each { |name, value| headers[name.to_s.downcase] = value }
154
+ end
155
+ headers
156
+ end
157
+
158
+ def raise_for_status(response)
159
+ status = response.code.to_i
160
+ return if status.between?(200, 299)
161
+
162
+ raise CompanyEnrich.api_error(
163
+ status: status, headers: response_headers(response), body: parse_error_body(response.read_body)
164
+ )
165
+ end
166
+
167
+ def response_headers(response)
168
+ response.to_hash.transform_values { |values| values.join(", ") }
169
+ end
170
+
171
+ # Hands each chunk to the caller. Network failures become SDK errors, while exceptions
172
+ # raised by the caller's own block pass through unchanged.
173
+ def read_chunks(response)
174
+ caller_failure = nil
175
+ response.read_body do |chunk|
176
+ yield chunk
177
+ rescue Exception => e # rubocop:disable Lint/RescueException
178
+ caller_failure = e
179
+ raise
180
+ end
181
+ rescue *TRANSPORT_ERRORS => e
182
+ raise if e.equal?(caller_failure)
183
+ raise APITimeoutError, "Download timed out: #{e.message}" if timeout_error?(e)
184
+
185
+ raise APIConnectionError, "Download was interrupted: #{e.message}"
186
+ end
187
+
188
+ def normalize_failure(error)
189
+ return error if error.is_a?(APIError)
190
+
191
+ if timeout_error?(error)
192
+ APITimeoutError.new("Request timed out: #{error.message}")
193
+ else
194
+ APIConnectionError.new("Could not reach the CompanyEnrich API: #{error.message}")
195
+ end
196
+ end
197
+
198
+ def timeout_error?(error)
199
+ TIMEOUT_ERRORS.any? { |type| error.is_a?(type) }
200
+ end
201
+
202
+ # @return [Numeric, nil] how long to wait before retrying, or nil if the request must not be retried
203
+ def retry_delay(failure, attempt, safe)
204
+ return safe ? backoff(attempt) : nil unless failure.is_a?(APIError)
205
+
206
+ status = failure.status
207
+ # A 429 is rejected before any work happens, so it is safe to repeat for every request.
208
+ return nil unless status == 429 || (safe && RETRYABLE_STATUSES.include?(status))
209
+
210
+ requested = parse_retry_after(failure.headers["retry-after"])
211
+ requested ||= parse_timespan(failure.headers["x-ratelimit-window"]) if status == 429
212
+ return backoff(attempt) if requested.nil?
213
+
214
+ requested <= MAX_SERVER_REQUESTED_WAIT ? requested : nil
215
+ end
216
+
217
+ def backoff(attempt)
218
+ ceiling = [MAX_BACKOFF, BASE_BACKOFF * (2**attempt)].min
219
+ ceiling * (0.75 + (rand * 0.25))
220
+ end
221
+
222
+ def parse_retry_after(value)
223
+ return nil if value.nil? || value.empty?
224
+
225
+ [Float(value), 0.0].max
226
+ rescue ArgumentError
227
+ begin
228
+ [Time.httpdate(value) - Time.now, 0.0].max
229
+ rescue ArgumentError
230
+ nil
231
+ end
232
+ end
233
+
234
+ # Parses a .NET TimeSpan such as 00:00:35.7551671 into seconds.
235
+ def parse_timespan(value)
236
+ match = TIMESPAN.match(value.to_s)
237
+ return nil unless match
238
+
239
+ (match[1].to_i * 3600) + (match[2].to_i * 60) + match[3].to_f
240
+ end
241
+
242
+ def parse_json(content, status)
243
+ return nil if content.nil? || content.empty?
244
+
245
+ JSON.parse(content)
246
+ rescue JSON::ParserError
247
+ raise Error, "The API returned an invalid JSON response (status #{status})"
248
+ end
249
+
250
+ def parse_error_body(content)
251
+ text = content.to_s.dup.force_encoding(Encoding::UTF_8).scrub
252
+ JSON.parse(text)
253
+ rescue JSON::ParserError
254
+ text
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "errors"
6
+
7
+ module CompanyEnrich
8
+ # Splits a byte stream into JSON records, keeping partial lines between chunks.
9
+ # @api private
10
+ class NdjsonDecoder
11
+ def initialize
12
+ @buffer = String.new(encoding: Encoding::BINARY)
13
+ end
14
+
15
+ # @return [Array] the records completed by this chunk
16
+ def feed(chunk)
17
+ @buffer << chunk.b
18
+ lines = @buffer.split("\n", -1)
19
+ @buffer = lines.pop || String.new(encoding: Encoding::BINARY)
20
+ parse(lines)
21
+ end
22
+
23
+ # @return [Array] the final record, if the stream did not end with a newline
24
+ def flush
25
+ rest = @buffer
26
+ @buffer = String.new(encoding: Encoding::BINARY)
27
+ parse([rest])
28
+ end
29
+
30
+ private
31
+
32
+ def parse(lines)
33
+ lines.reject { |line| line.strip.empty? }.map do |line|
34
+ JSON.parse(line.dup.force_encoding(Encoding::UTF_8))
35
+ rescue JSON::ParserError
36
+ raise Error, "The API returned an invalid NDJSON record"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support"
4
+
5
+ module CompanyEnrich
6
+ module Resources
7
+ # An asynchronous job: create it, wait with +batches.wait_for_completion+, then download its results.
8
+ class BatchJob
9
+ # @api private
10
+ def initialize(http, path)
11
+ @http = http
12
+ @path = path
13
+ end
14
+
15
+ # Downloads every settled record as one JSON document.
16
+ def results(batch_id, sig: nil, options: nil)
17
+ @http.request("GET", results_path(batch_id), query: { format: "json", sig: sig }, options: options)
18
+ end
19
+
20
+ # Streams settled records one at a time. Prefer this for large jobs.
21
+ # @return [Enumerator] when no block is given
22
+ def stream(batch_id, sig: nil, options: nil, &block)
23
+ enumerator = Enumerator.new do |yielder|
24
+ query = { format: "ndjson", sig: sig }
25
+ @http.stream_ndjson(results_path(batch_id), query: query, options: options) do |record|
26
+ yielder << record
27
+ end
28
+ end
29
+ Support.iterate(enumerator, block)
30
+ end
31
+
32
+ # Downloads the results as CSV text. Use {#csv_chunks} for large jobs.
33
+ # @return [String]
34
+ def csv(batch_id, sig: nil, options: nil)
35
+ csv_chunks(batch_id, sig: sig, options: options).to_a.join
36
+ .force_encoding(Encoding::UTF_8).delete_prefix("")
37
+ end
38
+
39
+ # Yields the CSV download as raw bytes, for example to write it to a file.
40
+ # @return [Enumerator] when no block is given
41
+ def csv_chunks(batch_id, sig: nil, options: nil, &block)
42
+ enumerator = Enumerator.new do |yielder|
43
+ query = { format: "csv", sig: sig }
44
+ @http.stream_chunks(results_path(batch_id), query: query, options: options) do |chunk|
45
+ yielder << chunk
46
+ end
47
+ end
48
+ Support.iterate(enumerator, block)
49
+ end
50
+
51
+ private
52
+
53
+ def create_job(body, options)
54
+ @http.request("POST", @path, body: body.compact, options: options)
55
+ end
56
+
57
+ def results_path(batch_id)
58
+ "#{@path}/#{Support.escape(batch_id)}/results"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "support"
5
+
6
+ module CompanyEnrich
7
+ module Resources
8
+ class Batches
9
+ DEFAULT_POLL_INTERVAL = 2.0
10
+
11
+ # @api private
12
+ def initialize(http)
13
+ @http = http
14
+ end
15
+
16
+ # Returns one page of your batches, newest first.
17
+ def list(kind: nil, page_size: nil, cursor: nil, options: nil)
18
+ @http.request("GET", "/v2/batches", query: { kind: kind, pageSize: page_size, cursor: cursor },
19
+ options: options)
20
+ end
21
+
22
+ # Iterates over all of your batches, newest first.
23
+ # @return [Enumerator] when no block is given
24
+ def list_all(kind: nil, page_size: nil, cursor: nil, options: nil, &block)
25
+ enumerator = Support.paginate(cursor) do |next_cursor|
26
+ list(kind: kind, page_size: page_size, cursor: next_cursor, options: options)
27
+ end
28
+ Support.iterate(enumerator, block)
29
+ end
30
+
31
+ # Retrieves a batch's status and progress.
32
+ def get(batch_id, options: nil)
33
+ @http.request("GET", "/v2/batches/#{Support.escape(batch_id)}", options: options)
34
+ end
35
+
36
+ # Stops remaining work. Results that were already charged stay downloadable.
37
+ def cancel(batch_id, options: nil)
38
+ @http.request("POST", "/v2/batches/#{Support.escape(batch_id)}/cancel", retry_safe: true, options: options)
39
+ end
40
+
41
+ # Streams the raw HTTP outcome of each request in the batch.
42
+ # @return [Enumerator] when no block is given
43
+ def raw_results(batch_id, sig: nil, options: nil, &block)
44
+ enumerator = Enumerator.new do |yielder|
45
+ path = "/v2/batches/#{Support.escape(batch_id)}/results"
46
+ @http.stream_ndjson(path, query: { sig: sig }, options: options) { |record| yielder << record }
47
+ end
48
+ Support.iterate(enumerator, block)
49
+ end
50
+
51
+ # Polls until the batch is +completed+ or +cancelled+, then returns its final status.
52
+ # Yields the latest status after every check. Raises {APITimeoutError} if +timeout+ seconds
53
+ # pass first, and waits indefinitely by default.
54
+ def wait_for_completion(batch_id, poll_interval: DEFAULT_POLL_INTERVAL, timeout: nil, options: nil)
55
+ deadline = timeout && (now + timeout)
56
+ while true # rubocop:disable Style/InfiniteLoop
57
+ batch = get(batch_id, options: options)
58
+ yield batch if block_given?
59
+ return batch if %w[completed cancelled].include?(batch["status"])
60
+
61
+ wait = poll_interval
62
+ if deadline
63
+ remaining = deadline - now
64
+ raise APITimeoutError, "Batch #{batch_id} did not finish within #{timeout} seconds" if remaining <= 0
65
+
66
+ wait = [poll_interval, remaining].min
67
+ end
68
+ @http.pause(wait)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def now
75
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "batch_job"
4
+ require_relative "support"
5
+
6
+ module CompanyEnrich
7
+ module Resources
8
+ # Asynchronous enrichment of up to 10,000 domains.
9
+ class CompanyBulkEnrichment < BatchJob
10
+ # Starts the job. It runs in the background and consumes credits as it progresses.
11
+ def create(domains, expand: nil, wait_for_enrichment: nil, options: nil)
12
+ create_job({ domains: domains, expand: expand, waitForEnrichment: wait_for_enrichment }, options)
13
+ end
14
+ end
15
+
16
+ # Asynchronous export of a company search.
17
+ class CompanySearchExport < BatchJob
18
+ # Starts the export. It runs in the background and consumes credits as it progresses.
19
+ def create(limit, filter: nil, semantic: nil, lookalike: nil, expand: nil, options: nil)
20
+ create_job({ limit: limit, filter: filter, semantic: semantic, lookalike: lookalike, expand: expand }, options)
21
+ end
22
+ end
23
+
24
+ class Companies
25
+ # @return [CompanyBulkEnrichment]
26
+ attr_reader :bulk_enrichment
27
+ # @return [CompanySearchExport]
28
+ attr_reader :search_export
29
+
30
+ # @api private
31
+ def initialize(http)
32
+ @http = http
33
+ @bulk_enrichment = CompanyBulkEnrichment.new(http, "/v2/companies/enrich/bulk")
34
+ @search_export = CompanySearchExport.new(http, "/v2/companies/search/export")
35
+ end
36
+
37
+ # Retrieves a stored company by its CompanyEnrich ID.
38
+ def get(company_id, expand: nil, options: nil)
39
+ @http.request("GET", "/v2/companies/#{Support.escape(company_id)}", query: { expand: expand }, options: options)
40
+ end
41
+
42
+ # Enriches a company by domain. Raises {NotFoundError} when there is no match.
43
+ def enrich(domain, expand: nil, wait_for_enrichment: nil, options: nil)
44
+ query = { domain: domain, expand: expand, waitForEnrichment: wait_for_enrichment }
45
+ @http.request("GET", "/v2/companies/enrich", query: query, options: options)
46
+ end
47
+
48
+ # Enriches a company from identifying properties such as its name or LinkedIn URL.
49
+ def enrich_by_properties(name: nil, linkedin_url: nil, linkedin_id: nil, twitter_url: nil, facebook_url: nil,
50
+ instagram_url: nil, youtube_url: nil, expand: nil, options: nil)
51
+ body = {
52
+ name: name, linkedinUrl: linkedin_url, linkedinId: linkedin_id, twitterUrl: twitter_url,
53
+ facebookUrl: facebook_url, instagramUrl: instagram_url, youtubeUrl: youtube_url, expand: expand
54
+ }.compact
55
+ @http.request("POST", "/v2/companies/enrich", body: body, options: options)
56
+ end
57
+
58
+ # Enriches up to 50 domains in one request.
59
+ def enrich_batch(domains, expand: nil, wait_for_enrichment: nil, options: nil)
60
+ body = { domains: domains, expand: expand, waitForEnrichment: wait_for_enrichment }.compact
61
+ @http.request("POST", "/v2/companies/enrich/batch", body: body, options: options)
62
+ end
63
+
64
+ # Suggests up to 10 companies whose domain matches +query+.
65
+ def autocomplete(query, options: nil)
66
+ @http.request("GET", "/v2/companies/autocomplete", query: { query: query }, options: options)
67
+ end
68
+
69
+ # Retrieves workforce insights for exactly one of +company_id+ or +domain+.
70
+ # Pass +nextCursor+ from the response as +cursor+ to go back another 12 months.
71
+ def workforce(company_id: nil, domain: nil, cursor: nil, options: nil)
72
+ raise ArgumentError, "Provide exactly one of `company_id:` or `domain:`." if company_id.nil? == domain.nil?
73
+
74
+ query = { id: company_id, domain: domain, cursor: cursor }
75
+ @http.request("GET", "/v2/companies/workforce", query: query, options: options)
76
+ end
77
+
78
+ # Returns one page of companies matching filters, a semantic query, or lookalike seeds.
79
+ def search(filter: nil, semantic: nil, lookalike: nil, page_size: nil, cursor: nil, expand: nil, options: nil)
80
+ body = {
81
+ filter: filter, semantic: semantic, lookalike: lookalike,
82
+ pageSize: page_size, cursor: cursor, expand: expand
83
+ }.compact
84
+ @http.request("POST", "/v2/companies/search", body: body, options: options)
85
+ end
86
+
87
+ # Iterates over every matching company, fetching pages as needed. Accepts the same arguments as {#search}.
88
+ # Credits are charged per page, so set +page_size+ close to what you will consume.
89
+ # @return [Enumerator] when no block is given
90
+ def search_all(cursor: nil, options: nil, **params, &block)
91
+ enumerator = Support.paginate(cursor) do |next_cursor|
92
+ search(**params, cursor: next_cursor, options: options)
93
+ end
94
+ Support.iterate(enumerator, block)
95
+ end
96
+
97
+ # Counts matching companies. Free.
98
+ def count(filter: nil, semantic: nil, lookalike: nil, options: nil)
99
+ body = { filter: filter, semantic: semantic, lookalike: lookalike }.compact
100
+ @http.request("POST", "/v2/companies/search/count", body: body, retry_safe: true, options: options)
101
+ end
102
+
103
+ # Returns up to 25 company summaries. Free, but requires a plan with previews.
104
+ def preview(filter: nil, semantic: nil, lookalike: nil, options: nil)
105
+ body = { filter: filter, semantic: semantic, lookalike: lookalike }.compact
106
+ @http.request("POST", "/v2/companies/search/preview", body: body, retry_safe: true, options: options)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support"
4
+
5
+ module CompanyEnrich
6
+ module Resources
7
+ # Geographic reference data for search filters. Every endpoint here is free.
8
+ class Geo
9
+ # @api private
10
+ def initialize(http)
11
+ @http = http
12
+ end
13
+
14
+ # Lists all regions and their country codes.
15
+ def regions(options: nil)
16
+ @http.request("GET", "/v2/geo/regions", options: options)
17
+ end
18
+
19
+ # Retrieves a country by its two-letter code.
20
+ def country(country_code, options: nil)
21
+ @http.request("GET", "/v2/geo/countries/#{Support.escape(country_code)}", options: options)
22
+ end
23
+
24
+ def search_countries(query: nil, page_size: nil, cursor: nil, options: nil)
25
+ search("/v2/geo/countries", { query: query, pageSize: page_size, cursor: cursor }, options)
26
+ end
27
+
28
+ # @return [Enumerator] when no block is given
29
+ def search_countries_all(cursor: nil, options: nil, **params, &block)
30
+ enumerator = Support.paginate(cursor) do |next_cursor|
31
+ search_countries(**params, cursor: next_cursor, options: options)
32
+ end
33
+ Support.iterate(enumerator, block)
34
+ end
35
+
36
+ def search_states(country_codes: nil, query: nil, page_size: nil, cursor: nil, options: nil)
37
+ body = { countryCodes: country_codes, query: query, pageSize: page_size, cursor: cursor }
38
+ search("/v2/geo/states", body, options)
39
+ end
40
+
41
+ # @return [Enumerator] when no block is given
42
+ def search_states_all(cursor: nil, options: nil, **params, &block)
43
+ enumerator = Support.paginate(cursor) do |next_cursor|
44
+ search_states(**params, cursor: next_cursor, options: options)
45
+ end
46
+ Support.iterate(enumerator, block)
47
+ end
48
+
49
+ def search_cities(state_ids: nil, country_codes: nil, query: nil, page_size: nil, cursor: nil, options: nil)
50
+ body = { stateIds: state_ids, countryCodes: country_codes, query: query, pageSize: page_size, cursor: cursor }
51
+ search("/v2/geo/cities", body, options)
52
+ end
53
+
54
+ # @return [Enumerator] when no block is given
55
+ def search_cities_all(cursor: nil, options: nil, **params, &block)
56
+ enumerator = Support.paginate(cursor) do |next_cursor|
57
+ search_cities(**params, cursor: next_cursor, options: options)
58
+ end
59
+ Support.iterate(enumerator, block)
60
+ end
61
+
62
+ private
63
+
64
+ def search(path, body, options)
65
+ @http.request("POST", path, body: body.compact, retry_safe: true, options: options)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "batch_job"
4
+ require_relative "support"
5
+
6
+ module CompanyEnrich
7
+ module Resources
8
+ # Asynchronous export of a people search.
9
+ class PersonSearchExport < BatchJob
10
+ # Starts the export. It runs in the background and consumes credits as it progresses.
11
+ def create(limit, filter: nil, semantic: nil, company: nil, expand: nil, options: nil)
12
+ create_job({ limit: limit, filter: filter, semantic: semantic, company: company, expand: expand }, options)
13
+ end
14
+ end
15
+
16
+ class People
17
+ # @return [PersonSearchExport]
18
+ attr_reader :search_export
19
+
20
+ # @api private
21
+ def initialize(http)
22
+ @http = http
23
+ @search_export = PersonSearchExport.new(http, "/v2/people/search/export")
24
+ end
25
+
26
+ # Retrieves a person by their CompanyEnrich ID.
27
+ def get(person_id, expand: nil, options: nil)
28
+ @http.request("GET", "/v2/people/#{Support.escape(person_id)}", query: { expand: expand }, options: options)
29
+ end
30
+
31
+ # Identifies a person from an email. Raises {NotFoundError} if there is no match.
32
+ def enrich(email, expand: nil, options: nil)
33
+ @http.request("POST", "/v2/people/enrich", body: { email: email, expand: expand }.compact, options: options)
34
+ end
35
+
36
+ # Finds a person's work email. Check +status+: only +found+ includes +email+.
37
+ def find_email(person_id, domain: nil, options: nil)
38
+ @http.request("GET", "/v2/people/#{Support.escape(person_id)}/email", query: { domain: domain },
39
+ options: options)
40
+ end
41
+
42
+ # Returns one page of people matching filters, a semantic query, or a company query.
43
+ def search(filter: nil, semantic: nil, company: nil, page_size: nil, cursor: nil, expand: nil, options: nil)
44
+ body = {
45
+ filter: filter, semantic: semantic, company: company,
46
+ pageSize: page_size, cursor: cursor, expand: expand
47
+ }.compact
48
+ @http.request("POST", "/v2/people/search", body: body, options: options)
49
+ end
50
+
51
+ # Iterates over every matching person, fetching pages as needed. Accepts the same arguments as {#search}.
52
+ # Credits are charged per page, so set +page_size+ close to what you will consume.
53
+ # @return [Enumerator] when no block is given
54
+ def search_all(cursor: nil, options: nil, **params, &block)
55
+ enumerator = Support.paginate(cursor) do |next_cursor|
56
+ search(**params, cursor: next_cursor, options: options)
57
+ end
58
+ Support.iterate(enumerator, block)
59
+ end
60
+
61
+ # Counts matching people. Free.
62
+ def count(filter: nil, semantic: nil, company: nil, options: nil)
63
+ body = { filter: filter, semantic: semantic, company: company }.compact
64
+ @http.request("POST", "/v2/people/search/count", body: body, retry_safe: true, options: options)
65
+ end
66
+
67
+ # Returns up to 10 person summaries. Free, but requires a plan with previews.
68
+ def preview(filter: nil, semantic: nil, company: nil, options: nil)
69
+ body = { filter: filter, semantic: semantic, company: company }.compact
70
+ @http.request("POST", "/v2/people/search/preview", body: body, retry_safe: true, options: options)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompanyEnrich
4
+ module Resources
5
+ class Industries
6
+ # @api private
7
+ def initialize(http)
8
+ @http = http
9
+ end
10
+
11
+ # Lists all industries and their NAICS code prefixes. Free.
12
+ def list(options: nil)
13
+ @http.request("GET", "/v2/industries", options: options)
14
+ end
15
+ end
16
+
17
+ # Suggests up to 10 values for a search filter, ordered by popularity. Free.
18
+ class Autocomplete
19
+ # @api private
20
+ def initialize(http, path)
21
+ @http = http
22
+ @path = path
23
+ end
24
+
25
+ def autocomplete(query, options: nil)
26
+ @http.request("GET", @path, query: { query: query }, options: options)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module CompanyEnrich
6
+ module Resources
7
+ # Helpers shared by the resource classes.
8
+ # @api private
9
+ module Support
10
+ module_function
11
+
12
+ def escape(value)
13
+ ERB::Util.url_encode(value.to_s)
14
+ end
15
+
16
+ # Runs the enumerator with the caller's block, or returns it for lazy use.
17
+ def iterate(enumerator, block)
18
+ block ? enumerator.each(&block) : enumerator
19
+ end
20
+
21
+ # Builds a lazy enumerator that requests a page only when it is needed.
22
+ def paginate(first_cursor, &fetch_page)
23
+ Enumerator.new do |yielder|
24
+ cursor = first_cursor
25
+ fetching = true
26
+ while fetching
27
+ page = fetch_page.call(cursor)
28
+ page.fetch("items").each { |item| yielder << item }
29
+ cursor = page["nextCursor"]
30
+ fetching = !(cursor.nil? || cursor.empty?)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompanyEnrich
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "companyenrich/version"
4
+ require_relative "companyenrich/errors"
5
+ require_relative "companyenrich/client"
6
+
7
+ # Official Ruby SDK for the CompanyEnrich API.
8
+ module CompanyEnrich
9
+ end
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: companyenrich
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - CompanyEnrich
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Company and people enrichment, filter, semantic and lookalike search,
13
+ work emails, and bulk jobs through the CompanyEnrich API.
14
+ email:
15
+ - support@companyenrich.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - README.md
22
+ - lib/companyenrich.rb
23
+ - lib/companyenrich/client.rb
24
+ - lib/companyenrich/errors.rb
25
+ - lib/companyenrich/http_client.rb
26
+ - lib/companyenrich/ndjson_decoder.rb
27
+ - lib/companyenrich/resources/batch_job.rb
28
+ - lib/companyenrich/resources/batches.rb
29
+ - lib/companyenrich/resources/companies.rb
30
+ - lib/companyenrich/resources/geo.rb
31
+ - lib/companyenrich/resources/people.rb
32
+ - lib/companyenrich/resources/reference.rb
33
+ - lib/companyenrich/resources/support.rb
34
+ - lib/companyenrich/version.rb
35
+ homepage: https://github.com/CompanyEnrich/companyenrich-ruby-sdk
36
+ licenses:
37
+ - MIT
38
+ metadata:
39
+ homepage_uri: https://companyenrich.com
40
+ source_code_uri: https://github.com/CompanyEnrich/companyenrich-ruby-sdk
41
+ bug_tracker_uri: https://github.com/CompanyEnrich/companyenrich-ruby-sdk/issues
42
+ rubygems_mfa_required: 'true'
43
+ rdoc_options: []
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '3.1'
51
+ required_rubygems_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ requirements: []
57
+ rubygems_version: 3.6.9
58
+ specification_version: 4
59
+ summary: Official Ruby SDK for the CompanyEnrich API.
60
+ test_files: []