namegender 0.3.1 → 0.5.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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +96 -5
  3. data/lib/namegender.rb +224 -19
  4. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f722edcb4239a867f707088e2ce93a6b3405fe0a0f1749737954120449a723e
4
- data.tar.gz: ed030b15d314cd420bdc0157966dbc064c81d0e9cd23a20a6bf540b4e1a1a674
3
+ metadata.gz: 5350923af9d635c3f939d1f25538c60b6accc0defeb05cf22039573ff89e10fe
4
+ data.tar.gz: 39dcaef2b09d1357e6a6bab9d47f4ac5136e936e4b3a1d1a66bea68ae315f583
5
5
  SHA512:
6
- metadata.gz: 37278319b8f4aa7e36e24cdffa06f84eea05c96cfbe699797695406a3213825e9d3493e780a194c4303d146dd8aa5d5f33e2f5cb91714510bbdcdd073a9e18c9
7
- data.tar.gz: 2b6348cb0cefe459729e14179a731e31f95edb6d9e4fad1cc9729f34162ae445e9837cb58606ccdce1b12a457f7746c6679e0c08194b0980a5f585beeadf073d
6
+ metadata.gz: fe4b4f3e318ebb9f6ff60ff3d8492cfd83d2e1fb068208563a966c3a5fc5dad1e84289956d31c47b9cd2edddadace69f6a216074922276e284689ba5c2ae3d97
7
+ data.tar.gz: 036b301d1b82c4443657cbaa9a38351de62bb15c47c7621741475a0e7fcc3c46c1085c7f2b3fde0ae0ff558de84383645341e5f35f0a78f8b2f7a36cdd3dbe4a
data/README.md CHANGED
@@ -12,19 +12,33 @@ puts result["gender"], result["probability"], result["sample_size"]
12
12
 
13
13
  ## Options and response
14
14
 
15
- `name`, `email`, `username` and `bulk` accept `country:`, `ai_fallback:` and
16
- `best_guess:`:
15
+ `name`, `email`, `username` and `bulk` accept `country:`, `locale:`, `ip:`,
16
+ `ai_fallback:` and `best_guess:`:
17
17
 
18
18
  ```ruby
19
19
  result = client.name("Andrea", country: "IT", best_guess: true)
20
20
  ```
21
21
 
22
- A result carries `query`, `name`, `gender`, `country`, `probability`,
23
- `sample_size`, `took_ms`, `source`, `confidence` and `matched_as`, alongside
22
+ When you do not know the country, pass what you have and the API works it out:
23
+ `locale:` is a language tag such as `"it-IT"` or `"pt_BR"` (its region is used;
24
+ a tag without one, `"en"`, sets no country), and `ip:` is your end user's IP
25
+ address (looked up, not stored). `country:` wins over `locale:`, which wins
26
+ over `ip:`. The response's `country_source` says which one was used:
27
+ `"country"`, `"locale"`, `"ip"` or `nil`.
28
+
29
+ ```ruby
30
+ result = client.name("Andrea", locale: request.env["HTTP_ACCEPT_LANGUAGE"].to_s[/\A[^,;]+/], ip: request.ip)
31
+ puts result["country"], result["country_source"]
32
+ ```
33
+
34
+ A result carries `query`, `name`, `first_name`, `middle_name`, `last_name`, `name_type`, `gender`, `country`, `probability`,
35
+ `sample_size`, `took_ms`, `source`, `confidence`, `matched_as` and `country_source`, alongside
24
36
  `credits_charged`, `credits_remaining`, `data_version` and `request_id`.
25
37
  Success is the HTTP status: any non-2xx response raises `NameGender::Error`
26
38
  with `status` and `body` (`{"error", "message", "request_id", "docs"}`).
27
- Branch on `body["error"]`, not on the message.
39
+ Branch on `body["error"]`, not on the message. A `bulk` response carries
40
+ `country_source` once, at the top level next to `summary`, not on each item
41
+ in `results`.
28
42
 
29
43
  ## Country distribution
30
44
 
@@ -38,3 +52,80 @@ result = client.countries("Mehmet", limit: 10)
38
52
  result["registrations"].each { |r| puts "#{r["country"]} #{r["share"]}%" }
39
53
  puts result["attested_in"].join(", ")
40
54
  ```
55
+
56
+ ## File jobs
57
+
58
+ Upload a CSV or XLSX file (up to 100 MB and 1,000,000 rows) and get it back
59
+ with gender columns added. One credit per row, charged only if the job
60
+ completes.
61
+
62
+ ```ruby
63
+ job = client.batches.create(
64
+ "customers.csv", # a path, a binary String (with filename:) or an IO
65
+ name_column: "first_name", # required to start
66
+ country_column: "country" # optional: a country code per row
67
+ )
68
+
69
+ done = client.batches.wait(job["id"]) { |j| puts j["progress"] }
70
+ raise done["error"]["code"] if done["status"] == "failed"
71
+
72
+ client.batches.download(done["id"], "customers-gender.csv")
73
+ ```
74
+
75
+ `name_column` is required to start: a guessed column that turns out to be
76
+ wrong would spend credits on the wrong data. To see the columns and the cost
77
+ first, upload with `start: false`, read `job["inspection"]`, then call
78
+ `client.batches.start(job["id"], name_column: ...)`.
79
+
80
+ `create` sends an `Idempotency-Key` and retries network errors and 502/503/504
81
+ with the same key, so a retry never opens a second job. Pass your own
82
+ `idempotency_key:` to keep that guarantee across your own retries.
83
+
84
+ `wait` returns a failed job rather than raising; branch on
85
+ `job["error"]["code"]`. `cancel` returns the credit of a job that has not
86
+ started, and deletes a finished one. `list(limit:, page:)` includes jobs
87
+ started from the dashboard. Up to three jobs can be queued or running at once;
88
+ a fourth is refused with `429 too_many_batches`.
89
+
90
+ The result appends `gender`, `probability`, `sample_size`, `country`, `source`,
91
+ `matched_as`, `first_name`, `middle_name`, `last_name` and `name_type` to every
92
+ row. A CSV result starts with a UTF-8 byte order mark; read it with
93
+ `File.read(path, encoding: "bom|utf-8")`.
94
+
95
+ ## Webhooks
96
+
97
+ Add an endpoint under Webhooks in the dashboard, and NameGender sends a signed
98
+ `POST` to it when a file job completes or fails, and when credits are about to
99
+ run out (`credits.low`) or have run out (`credits.depleted`, checked hourly).
100
+ `NameGender::Webhooks.verify` checks the signature and the timestamp, and
101
+ returns the event.
102
+
103
+ ```ruby
104
+ require "sinatra"
105
+ require "namegender"
106
+
107
+ post "/namegender" do
108
+ begin
109
+ event = NameGender::Webhooks.verify(
110
+ request.body.read, # the raw body, not a parsed params hash (request.raw_post in Rails)
111
+ request.env["HTTP_NAMEGENDER_SIGNATURE"],
112
+ ENV.fetch("NAMEGENDER_WEBHOOK_SECRET")
113
+ )
114
+ rescue NameGender::WebhookVerificationError
115
+ halt 400
116
+ end
117
+
118
+ if event["type"] == "batch.completed"
119
+ job = event["data"]["object"] # the job, as batches.get returns it
120
+ # ...
121
+ end
122
+ 204
123
+ end
124
+ ```
125
+
126
+ The signature covers the exact bytes sent: parsing the JSON and serialising it
127
+ again changes them, and the check fails. Answer quickly and do slow work
128
+ afterwards. Anything other than a 2xx within 10 seconds is retried, up to 8
129
+ attempts over about 45 hours. Use `event["id"]` (also the `NameGender-Event-Id`
130
+ header) to ignore a delivery you have already handled: a retry carries the same
131
+ id, and order is not guaranteed.
data/lib/namegender.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  require "json"
2
2
  require "net/http"
3
+ require "openssl"
4
+ require "securerandom"
3
5
  require "uri"
4
6
 
5
7
  module NameGender
@@ -10,27 +12,37 @@ module NameGender
10
12
  end
11
13
  end
12
14
 
15
+ # The request is not a genuine NameGender webhook. Answer it with 400.
16
+ class WebhookVerificationError < Error; end
17
+
13
18
  class Client
14
19
  def initialize(api_key, base_url: "https://namegender.com/api/v1")
15
20
  raise ArgumentError, "api_key is required" if api_key.to_s.empty?
16
21
  @api_key, @base_url = api_key, base_url.sub(%r{/$}, "")
17
22
  end
18
23
 
19
- # `options` are sent as-is: `ai_fallback: true` falls back to a language
20
- # model for names not in the database (needs AI consent on the account),
21
- # `best_guess: true` returns the most likely gender even below the
22
- # probability threshold. Any non-2xx response raises NameGender::Error.
23
- def name(value, country: nil, **options)
24
- post("/gender", { name: value, country: country }.merge(options).compact)
24
+ # `country` is an ISO 3166-1 alpha-2 code. Without it, `locale` (a
25
+ # language tag such as "it-IT" or "pt_BR"; a tag without a region, "en",
26
+ # sets no country) and then `ip` (the end user's IP address, not stored by
27
+ # the API) supply one: country > locale > ip. The result's
28
+ # "country_source" says which was used ("country", "locale", "ip" or nil).
29
+ #
30
+ # Other `options` are sent as-is: `ai_fallback: true` falls back to a
31
+ # language model for names not in the database (needs AI consent on the
32
+ # account), `best_guess: true` returns the most likely gender even below
33
+ # the probability threshold. Any non-2xx response raises NameGender::Error.
34
+ def name(value, country: nil, locale: nil, ip: nil, **options)
35
+ post("/gender", { name: value, country: country, locale: locale, ip: ip }.merge(options).compact)
25
36
  end
26
- def email(value, country: nil, **options)
27
- post("/gender/email", { email: value, country: country }.merge(options).compact)
37
+ def email(value, country: nil, locale: nil, ip: nil, **options)
38
+ post("/gender/email", { email: value, country: country, locale: locale, ip: ip }.merge(options).compact)
28
39
  end
29
- def username(value, country: nil, **options)
30
- post("/gender/username", { username: value, country: country }.merge(options).compact)
40
+ def username(value, country: nil, locale: nil, ip: nil, **options)
41
+ post("/gender/username", { username: value, country: country, locale: locale, ip: ip }.merge(options).compact)
31
42
  end
32
- def bulk(values, country: nil, type: "name", **options)
33
- post("/gender/bulk", { names: Array(values), country: country, type: type }.merge(options).compact)
43
+ # `country`, `locale` and `ip` apply to every name in the list.
44
+ def bulk(values, country: nil, locale: nil, ip: nil, type: "name", **options)
45
+ post("/gender/bulk", { names: Array(values), country: country, locale: locale, ip: ip, type: type }.merge(options).compact)
34
46
  end
35
47
  # Country distribution of a name. Not a country-of-origin or ethnicity
36
48
  # inference: "registrations" is counted volume, comparable only among the
@@ -43,23 +55,216 @@ module NameGender
43
55
  def account
44
56
  request(Net::HTTP::Get, "/me")
45
57
  end
58
+ # File jobs: upload a CSV or XLSX file, get it back with gender columns added.
59
+ def batches
60
+ @batches ||= Batches.new(self)
61
+ end
46
62
 
47
63
  private
48
64
  def post(path, body)
49
65
  request(Net::HTTP::Post, path, body)
50
66
  end
51
- def request(klass, path, body = nil)
67
+ def request(klass, path, body = nil, raw: nil, headers: {})
68
+ response = transmit(klass, path, body, raw: raw, headers: headers)
69
+ # 204 (a cancelled job) has no body.
70
+ return nil if response.body.to_s.empty?
71
+ JSON.parse(response.body)
72
+ rescue JSON::ParserError
73
+ raise Error.new("NameGender returned invalid JSON", response&.code.to_i)
74
+ end
75
+ # `raw` is [bytes, content type] for a body that is not JSON.
76
+ def transmit(klass, path, body = nil, raw: nil, headers: {})
52
77
  uri = URI(@base_url + path)
53
78
  req = klass.new(uri)
54
79
  req["Accept"] = req["Content-Type"] = "application/json"
55
80
  req["Authorization"] = "Bearer #{@api_key}"
56
- req.body = JSON.generate(body) if body
81
+ if raw
82
+ req.body, req["Content-Type"] = raw
83
+ elsif body
84
+ req.body = JSON.generate(body)
85
+ end
86
+ headers.each { |key, value| req[key] = value }
57
87
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }
58
- parsed = JSON.parse(response.body)
59
- raise Error.new(parsed["message"] || "HTTP #{response.code}", response.code.to_i, parsed) unless response.is_a?(Net::HTTPSuccess)
60
- parsed
61
- rescue JSON::ParserError
62
- raise Error.new("NameGender returned invalid JSON", response&.code.to_i)
88
+ return response if response.is_a?(Net::HTTPSuccess)
89
+ begin
90
+ parsed = JSON.parse(response.body.to_s)
91
+ rescue JSON::ParserError
92
+ raise Error.new("NameGender returned invalid JSON", response.code.to_i)
93
+ end
94
+ message = parsed.is_a?(Hash) && parsed["message"]
95
+ raise Error.new(message || "HTTP #{response.code}", response.code.to_i, parsed)
96
+ end
97
+ end
98
+
99
+ # File jobs. Reached as `client.batches`.
100
+ class Batches
101
+ FINISHED = %w[completed failed cancelled].freeze
102
+ # Statuses worth retrying an upload for: the request may never have reached
103
+ # the application. Everything else (402, 422, 429 too_many_batches) would
104
+ # fail the same way again.
105
+ RETRYABLE = [502, 503, 504].freeze
106
+ NETWORK_ERRORS = [IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError].freeze
107
+
108
+ def initialize(client)
109
+ @client = client
110
+ end
111
+
112
+ # Upload a file and, unless `start: false`, start it.
113
+ #
114
+ # `file` is a path (String or Pathname), a binary String (`File.binread`)
115
+ # together with `filename:`, or an IO. The extension of the file name
116
+ # (.csv, .xlsx) tells the API the format. `name_column:` is required to
117
+ # start: a guessed column that is wrong would spend credits on the wrong
118
+ # data. With `start: false` the job carries "inspection" (columns,
119
+ # preview, cost) and is started with #start.
120
+ #
121
+ # One Idempotency-Key is used for every attempt, so a retry after a
122
+ # dropped connection returns the first job instead of opening a second
123
+ # one and reserving credit twice.
124
+ def create(file, filename: nil, start: true, idempotency_key: nil, retries: 2, **settings)
125
+ content, name = read_file(file, filename)
126
+ fields = { "start" => start ? "true" : "false" }
127
+ settings.each { |key, value| fields[key.to_s] = value.to_s unless value.nil? }
128
+ raw = multipart(fields, "file", name, content)
129
+ headers = { "Idempotency-Key" => idempotency_key || SecureRandom.uuid }
130
+
131
+ attempt = 0
132
+ begin
133
+ call(Net::HTTP::Post, "/batches", raw: raw, headers: headers)
134
+ rescue Error => e
135
+ raise unless RETRYABLE.include?(e.status) && attempt < retries
136
+ pause(2**attempt); attempt += 1
137
+ retry
138
+ rescue *NETWORK_ERRORS
139
+ raise unless attempt < retries
140
+ pause(2**attempt); attempt += 1
141
+ retry
142
+ end
143
+ end
144
+
145
+ # Start a job uploaded with `start: false`.
146
+ def start(id, name_column:, **settings)
147
+ call(Net::HTTP::Post, "/batches/#{escape(id)}/start", { name_column: name_column }.merge(settings).compact)
148
+ end
149
+ def get(id)
150
+ call(Net::HTTP::Get, "/batches/#{escape(id)}")
151
+ end
152
+ # Newest first. Includes jobs started from the dashboard.
153
+ def list(limit: nil, page: nil)
154
+ query = URI.encode_www_form({ limit: limit, page: page }.compact)
155
+ call(Net::HTTP::Get, "/batches" + (query.empty? ? "" : "?#{query}"))
156
+ end
157
+ # Cancel a job that has not started (credit is returned), or delete a finished one.
158
+ def cancel(id)
159
+ call(Net::HTTP::Delete, "/batches/#{escape(id)}")
160
+ nil
161
+ end
162
+
163
+ # Poll until the job is completed, failed or cancelled, and return it.
164
+ # A failed job is returned, not raised: check "status" and "error"["code"].
165
+ # `on_progress` (or a block) is called with the job after every poll.
166
+ def wait(id, timeout: 3600, on_progress: nil, &block)
167
+ on_progress ||= block
168
+ deadline = now + timeout
169
+ loop do
170
+ job = get(id)
171
+ on_progress&.call(job)
172
+ return job if FINISHED.include?(job["status"]) || job["status"] == "uploaded"
173
+ seconds = job["poll_after_seconds"] || 5
174
+ raise Error.new("Timed out waiting for #{id}", 0, job) if now + seconds > deadline
175
+ pause(seconds)
176
+ end
177
+ end
178
+
179
+ # The result file as a binary String, or written to `path` (which is then returned).
180
+ def download(id, path = nil)
181
+ content = @client.send(:transmit, Net::HTTP::Get, "/batches/#{escape(id)}/result").body.to_s.b
182
+ return content if path.nil?
183
+ File.binwrite(path, content)
184
+ path
185
+ end
186
+
187
+ private
188
+ def call(klass, path, body = nil, **options)
189
+ @client.send(:request, klass, path, body, **options)
190
+ end
191
+ def pause(seconds)
192
+ sleep(seconds)
193
+ end
194
+ def now
195
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
196
+ end
197
+ def escape(id)
198
+ id.to_s.b.gsub(/[^A-Za-z0-9\-._~]/) { |char| format("%%%02X", char.ord) }
199
+ end
200
+ def read_file(file, filename)
201
+ if file.is_a?(String) && file.encoding == Encoding::BINARY
202
+ raise ArgumentError, "filename is required when file is a binary String" if filename.to_s.empty?
203
+ [file, filename]
204
+ elsif file.is_a?(String) || (defined?(Pathname) && file.is_a?(Pathname))
205
+ [File.binread(file.to_s), filename || File.basename(file.to_s)]
206
+ elsif file.respond_to?(:read)
207
+ name = filename || (File.basename(file.path.to_s) if file.respond_to?(:path) && file.path)
208
+ raise ArgumentError, "filename is required when the IO has no path" if name.to_s.empty?
209
+ [file.read.to_s.b, name]
210
+ else
211
+ raise TypeError, "file must be a path, a binary String or an IO"
212
+ end
213
+ end
214
+ def multipart(fields, file_field, filename, content)
215
+ boundary = SecureRandom.hex(16)
216
+ lines = []
217
+ fields.each { |key, value| lines.push("--#{boundary}", %(Content-Disposition: form-data; name="#{key}"), "", value) }
218
+ # A quote or line break in a file name would end the header early.
219
+ safe = filename.to_s.gsub("\\", "\\\\\\\\").gsub('"', "%22").delete("\r\n")
220
+ head = (lines + [
221
+ "--#{boundary}",
222
+ %(Content-Disposition: form-data; name="#{file_field}"; filename="#{safe}"),
223
+ "Content-Type: application/octet-stream",
224
+ "", ""
225
+ ]).join("\r\n")
226
+ [head.b + content.b + "\r\n--#{boundary}--\r\n".b, "multipart/form-data; boundary=#{boundary}"]
227
+ end
228
+ end
229
+
230
+ # Webhook signature check.
231
+ #
232
+ # Pass the body exactly as received (`request.raw_post` in Rails,
233
+ # `request.body.read` in Sinatra and Rack). Parsing the JSON and serialising
234
+ # it again changes the bytes, and the signature no longer matches.
235
+ module Webhooks
236
+ module_function
237
+
238
+ # Check the NameGender-Signature header and return the parsed event.
239
+ # During a secret rotation the header carries two v1 values; either one
240
+ # matching is enough.
241
+ def verify(payload, signature_header, secret, tolerance: 300, now: nil)
242
+ raise ArgumentError, "secret is required" if secret.to_s.empty?
243
+ raise WebhookVerificationError, "Missing NameGender-Signature header" if signature_header.to_s.empty?
244
+ raise TypeError, "payload must be the raw request body (a String)" unless payload.is_a?(String)
245
+
246
+ timestamp = nil
247
+ signatures = []
248
+ signature_header.to_s.split(",").each do |part|
249
+ key, value = part.strip.split("=", 2)
250
+ if key == "t" && value.to_s.match?(/\A\d+\z/)
251
+ timestamp = value.to_i
252
+ elsif key == "v1" && !value.to_s.empty?
253
+ signatures << value
254
+ end
255
+ end
256
+ raise WebhookVerificationError, "Malformed NameGender-Signature header" if timestamp.nil? || signatures.empty?
257
+
258
+ if ((now || Time.now.to_f) - timestamp).abs > tolerance
259
+ raise WebhookVerificationError, "Webhook timestamp is outside the tolerance window"
260
+ end
261
+
262
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, "#{timestamp}.".b + payload.b)
263
+ unless signatures.any? { |signature| OpenSSL.secure_compare(expected, signature) }
264
+ raise WebhookVerificationError, "Webhook signature does not match"
265
+ end
266
+
267
+ JSON.parse(payload.b.force_encoding(Encoding::UTF_8))
63
268
  end
64
269
  end
65
270
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: namegender
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - NameGender
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-16 00:00:00.000000000 Z
11
+ date: 2026-10-06 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A dependency-free Ruby client for name, email, username, bulk and country
14
14
  distribution lookups through NameGender.