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