namegender 0.3.1 → 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.
- checksums.yaml +4 -4
- data/README.md +78 -1
- data/lib/namegender.rb +205 -7
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6579682547f30f447af4b578719b7ea7d4f907be742b6a51359f6ddf0d9732d7
|
|
4
|
+
data.tar.gz: a851e53689d40ec42beeffb8c4b71ffb826280b8e51935d0491994481f87e62a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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?
|
|
@@ -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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
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-
|
|
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.
|