genderapi 1.0.5 → 2.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.
@@ -1,215 +1,314 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "httparty"
4
3
  require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+ require "zlib"
5
8
 
6
9
  module GenderAPI
7
- ##
8
- # Ruby SDK for GenderAPI.io
10
+ # Client for the GenderAPI.io V2 API (https://api.genderapi.io/api/v2).
9
11
  #
10
- # This SDK allows determining gender from:
11
- # - personal names
12
- # - email addresses
13
- # - social media usernames
12
+ # Server-side only: keep the API key in your server environment and never
13
+ # embed it in browser or mobile code.
14
14
  #
15
- # Supports advanced options like:
16
- # - country filtering
17
- # - direct AI queries
18
- # - forced genderization for nicknames or unconventional strings
15
+ # Safety behaviour:
16
+ # * No automatic retries, ever (a lost response may still have been billed),
17
+ # including on HTTP 429: {RateLimitError#retry_after} is exposed instead.
18
+ # * Redirects are never followed; a 3xx raises {RedirectError}.
19
+ # * Timeout defaults to 10 seconds for each connect, write and read operation.
20
+ # * HTTPS only; plain http is accepted only for localhost/127.0.0.1/::1 (tests).
21
+ # * Constructing a client makes no request. The API key is sent only in the
22
+ # Authorization header, never in a URL, and is never logged or inspected.
19
23
  #
24
+ # Without an API key the server applies the shared IP trial (10 credits per
25
+ # 24 hours per public IP, batches of at most 10 items). The client has no
26
+ # trial logic of its own; the server decides.
20
27
  class Client
21
- include HTTParty
22
- base_uri "https://api.genderapi.io"
28
+ DEFAULT_BASE_URL = "https://api.genderapi.io/api/v2"
29
+ DEFAULT_TIMEOUT = 10
30
+ ENV_API_KEY = "GENDERAPI_API_KEY"
31
+ LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
32
+ ACCEPT = "application/json, application/problem+json"
23
33
 
24
- ##
25
- # Initialize the GenderAPI client.
26
- #
27
- # @param api_key [String] Your API key as a Bearer token.
28
- # @param base_url [String] Optional API base URL. Defaults to https://api.genderapi.io
29
- #
30
- def initialize(api_key:, base_url: nil)
31
- @api_key = api_key
32
- self.class.base_uri(base_url) if base_url
33
- @headers = {
34
- "Authorization" => "Bearer #{@api_key}",
35
- "Content-Type" => "application/json"
36
- }
34
+ # @return [String] normalised base URL without trailing slash
35
+ attr_reader :base_url
36
+ # @return [Numeric] timeout in seconds for each connect, write and read operation
37
+ attr_reader :timeout
38
+
39
+ # @param api_key [String, nil] API key. Defaults to ENV["GENDERAPI_API_KEY"].
40
+ # Without a key the server applies the shared IP trial.
41
+ # @param base_url [String] API base URL. Must be https, except for
42
+ # http://localhost, http://127.0.0.1 or http://[::1] (tests).
43
+ # @param timeout [Numeric] seconds for each connect, write and read operation (default 10)
44
+ # @param user_agent [String, nil] text appended to the default User-Agent
45
+ # @param require_api_key_access [Boolean] when a key is configured, raise
46
+ # {UnexpectedAccessModeError} if a response reports IP-trial or
47
+ # unauthenticated access (the key was not accepted). Default true.
48
+ def initialize(api_key: ENV.fetch(ENV_API_KEY, nil), base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
49
+ user_agent: nil, require_api_key_access: true)
50
+ @api_key = normalize_key(api_key)
51
+ @base_url, @base_uri = normalize_base_url(base_url)
52
+ unless timeout.is_a?(Numeric) && timeout.positive? && timeout.finite?
53
+ raise ValidationError.new("timeout must be a positive number of seconds", field: "timeout")
54
+ end
55
+
56
+ @timeout = timeout
57
+ if !user_agent.nil? && (!user_agent.is_a?(String) || user_agent.match?(/[\x00-\x1f\x7f]/))
58
+ raise ValidationError.new("user_agent must be a String without control characters", field: "user_agent")
59
+ end
60
+
61
+ @user_agent = ["genderapi-ruby/#{VERSION}", "(Ruby #{RUBY_VERSION})", user_agent].compact.join(" ")
62
+ @require_api_key_access = require_api_key_access ? true : false
37
63
  end
38
64
 
39
- ##
40
- # Determine gender from a personal name.
41
- #
42
- # @param name [String] The name to analyze. (Required)
43
- # @param country [String, nil] Optional two-letter country code (e.g. "US").
44
- # @param ask_to_ai [Boolean] Whether to force AI lookup. Default: false
45
- # @param force_to_genderize [Boolean] Whether to analyze nicknames or emojis. Default: false
46
- #
47
- # @return [Hash] JSON response as a Ruby Hash.
48
- #
49
- def get_gender_by_name(name:, country: nil, ask_to_ai: false, force_to_genderize: false)
50
- payload = {
51
- name: name,
52
- country: country,
53
- askToAI: ask_to_ai,
54
- forceToGenderize: force_to_genderize
55
- }
65
+ # @return [Boolean] whether an API key is configured
66
+ def api_key?
67
+ !@api_key.nil?
68
+ end
56
69
 
57
- _post_request("/api", payload)
70
+ # Never reveals the API key.
71
+ def inspect
72
+ "#<#{self.class.name} base_url=#{@base_url.inspect} timeout=#{@timeout} api_key=#{api_key? ? '[FILTERED]' : 'nil'}>"
58
73
  end
74
+ alias to_s inspect
59
75
 
60
- ##
61
- # Determine gender from an email address.
62
- #
63
- # @param email [String] The email to analyze. (Required)
64
- # @param country [String, nil] Optional two-letter country code (e.g. "US").
65
- # @param ask_to_ai [Boolean] Whether to force AI lookup. Default: false
66
- #
67
- # @return [Hash] JSON response as a Ruby Hash.
68
- #
69
- def get_gender_by_email(email:, country: nil, ask_to_ai: false)
70
- payload = {
71
- email: email,
72
- country: country,
73
- askToAI: ask_to_ai
74
- }
76
+ # Predict gender for one name, email or username (POST /gender).
77
+ #
78
+ # Every call is a new, billable operation. Unknown results are successful
79
+ # and billed. Single requests default to ai_mode "fallback" on the server
80
+ # (1 credit total); "always" costs 2; force_to_genderize true costs 1 for a
81
+ # dataset hit, otherwise 2 total.
82
+ #
83
+ # @param type [String, Symbol] "name", "email" or "username"
84
+ # @param value [String] 1-254 characters, no control characters
85
+ # @param country [String, nil] uppercase ISO 3166-1 alpha-2 code, e.g. "US"
86
+ # @param ai_mode [String, Symbol, nil] "off", "fallback" or "always" (sent as options.ai_mode)
87
+ # @param force_to_genderize [Boolean, nil] sent as forceToGenderize; not with ai_mode off/always
88
+ # @param id [String, nil] optional 1-64 character correlation id
89
+ # @return [GenderResult]
90
+ def gender(type, value, country: nil, ai_mode: nil, force_to_genderize: nil, id: nil)
91
+ body = Validation.gender_item(type, value, country: country, ai_mode: ai_mode,
92
+ force_to_genderize: force_to_genderize, id: id)
93
+ request(:post, "/gender", body: body, auth: true) { |raw, ctx| check_prediction(raw, ctx) && GenderResult.new(raw) }
94
+ end
75
95
 
76
- _post_request("/api/email", payload)
96
+ # @see #gender
97
+ def name(value, **options)
98
+ gender("name", value, **options)
77
99
  end
78
100
 
79
- ##
80
- # Determine gender from a social media username.
81
- #
82
- # @param username [String] The username to analyze. (Required)
83
- # @param country [String, nil] Optional two-letter country code (e.g. "US").
84
- # @param ask_to_ai [Boolean] Whether to force AI lookup. Default: false
85
- # @param force_to_genderize [Boolean] Whether to analyze nicknames or emojis. Default: false
86
- #
87
- # @return [Hash] JSON response as a Ruby Hash.
88
- #
89
- def get_gender_by_username(username:, country: nil, ask_to_ai: false, force_to_genderize: false)
90
- payload = {
91
- username: username,
92
- country: country,
93
- askToAI: ask_to_ai,
94
- forceToGenderize: force_to_genderize
95
- }
101
+ # @see #gender
102
+ def email(value, **options)
103
+ gender("email", value, **options)
104
+ end
96
105
 
97
- _post_request("/api/username", payload)
106
+ # @see #gender
107
+ def username(value, **options)
108
+ gender("username", value, **options)
98
109
  end
99
110
 
100
- ##
101
- # Bulk determine gender from multiple personal names.
102
- #
103
- # Allows sending up to 100 name records in a single request.
104
- # Useful for high-volume batch processing where performance
105
- # and cost efficiency are critical.
106
- #
107
- # Each name object can contain:
108
- # - name [String] The personal name to analyze. (Required)
109
- # - country [String, nil] Optional two-letter country code (e.g. "US").
110
- # - id [String, Integer, nil] Optional custom identifier to correlate input and output.
111
- #
112
- # @param data [Array<Hash>] Array of name data hashes.
113
- #
114
- # @return [Hash] JSON response as a Ruby Hash.
115
- #
116
- def get_gender_by_name_bulk(data:)
117
- payload = { data: data }
118
- _post_request("/api/name/multi/country", payload)
111
+ # Predict 1-50 items in one request (POST /gender/batch). The server may
112
+ # allow fewer (10 on the IP trial). Batch items default to ai_mode "off".
113
+ #
114
+ # Each item is a Hash with :type, :value and optional :country, :id,
115
+ # :ai_mode and :force_to_genderize (wire names "forceToGenderize" and
116
+ # "options" => {"ai_mode" => ...} are accepted too). Ids must be unique.
117
+ #
118
+ # Partial success is returned, not raised: inspect {BatchResult#failed_items}.
119
+ # If every executed item fails the server answers with an error status and
120
+ # an {APIError} is raised whose {APIError#items} holds the item outcomes.
121
+ #
122
+ # @param items [Array<Hash>]
123
+ # @return [BatchResult]
124
+ def gender_batch(items)
125
+ wire = Validation.batch_items(items)
126
+ request(:post, "/gender/batch", body: { "items" => wire }, auth: true) do |raw, ctx|
127
+ check_batch(raw, ctx, wire) && BatchResult.new(raw)
128
+ end
119
129
  end
120
130
 
121
- ##
122
- # Bulk determine gender from multiple email addresses.
123
- #
124
- # Allows sending up to 50 email records in a single request.
125
- # This method is designed for scenarios such as bulk database
126
- # cleaning, analytics, or personalization tasks where email
127
- # data is available and high throughput is required.
128
- #
129
- # Each email object can contain:
130
- # - email [String] The email address to analyze. (Required)
131
- # - country [String, nil] Optional two-letter country code (e.g. "US").
132
- # - id [String, Integer, nil] Optional custom identifier to correlate input and output.
133
- #
134
- # @param data [Array<Hash>] Array of email data hashes.
135
- #
136
- # @return [Hash] JSON response as a Ruby Hash.
137
- #
138
- def get_gender_by_email_bulk(data:)
139
- payload = { data: data }
140
- _post_request("/api/email/multi/country", payload)
131
+ # Current balance and trial quota (GET /usage). Free.
132
+ # @return [UsageResult]
133
+ def usage
134
+ request(:get, "/usage", auth: true) { |raw, ctx| check_envelope(raw, ctx) && UsageResult.new(raw) }
141
135
  end
142
136
 
143
- ##
144
- # Bulk determine gender from multiple social media usernames.
145
- #
146
- # Allows sending up to 50 username records in a single request.
147
- # Useful for bulk social media analytics, profiling, or
148
- # marketing segmentation tasks where usernames are the primary
149
- # identifier and high performance is required.
150
- #
151
- # Each username object can contain:
152
- # - username [String] The social media username to analyze. (Required)
153
- # - country [String, nil] Optional two-letter country code (e.g. "US").
154
- # - id [String, Integer, nil] Optional custom identifier to correlate input and output.
155
- #
156
- # @param data [Array<Hash>] Array of username data hashes.
157
- #
158
- # @return [Hash] JSON response as a Ruby Hash.
159
- #
160
- def get_gender_by_username_bulk(data:)
161
- payload = { data: data }
162
- _post_request("/api/username/multi/country", payload)
137
+ # Validate a phone number's structure (POST /phone/validate). Costs 1 credit,
138
+ # including invalid numbers. It does not check subscriber existence.
139
+ #
140
+ # @param number [String] 3-32 characters: digits, spaces, parentheses, hyphens, optional leading +
141
+ # @param country [String, nil] ISO alpha-2 code; required unless number starts with +
142
+ # @return [PhoneResult]
143
+ def validate_phone(number, country: nil)
144
+ body = Validation.phone(number, country)
145
+ request(:post, "/phone/validate", body: body, auth: true) { |raw, ctx| check_envelope(raw, ctx) && PhoneResult.new(raw) }
146
+ end
147
+
148
+ # Deployment version, limits and AI availability (GET /). No key is sent.
149
+ # @return [Hash] parsed JSON
150
+ def capabilities
151
+ request(:get, "", auth: false) { |raw, _ctx| raw }
152
+ end
153
+
154
+ # Public machine-readable error catalog (GET /errors). No key is sent.
155
+ # @return [Hash] parsed JSON ({"errors" => {code => {...}}})
156
+ def error_catalog
157
+ request(:get, "/errors", auth: false) { |raw, _ctx| raw }
163
158
  end
164
159
 
165
160
  private
166
161
 
167
- ##
168
- # Internal helper to send POST requests to the GenderAPI.io API.
169
- #
170
- # Handles:
171
- # - Bearer authentication
172
- # - Removal of nil values in payload
173
- # - JSON parsing of responses
174
- # - Raising errors for non-200 responses
175
- #
176
- # @param endpoint [String] API endpoint path (e.g. "/api")
177
- # @param payload [Hash] Request body data.
178
- #
179
- # @return [Hash] JSON response as Ruby Hash.
180
- #
181
- def _post_request(endpoint, payload)
182
- # Remove nil values from payload
183
- cleaned_payload = payload.reject { |_k, v| v.nil? }
184
-
185
- response = self.class.post(
186
- endpoint,
187
- headers: @headers,
188
- body: JSON.generate(cleaned_payload)
189
- )
190
-
191
- # Handle server-side errors or timeouts
192
- if [500, 502, 503, 504, 408].include?(response.code)
193
- raise "GenderAPI Server Error or Timeout: HTTP #{response.code} - #{response.body}"
194
- else
195
- begin
196
- parse_json(response.body)
197
- rescue JSON::ParserError
198
- raise "GenderAPI Response is not valid JSON"
199
- end
162
+ def normalize_key(key)
163
+ return nil if key.nil?
164
+ raise ValidationError.new("api_key must be a String", field: "api_key") unless key.is_a?(String)
165
+
166
+ key = key.strip
167
+ return nil if key.empty?
168
+ if key.match?(/[\x00-\x20\x7f]/) || !key.ascii_only?
169
+ raise ValidationError.new("api_key contains invalid characters", field: "api_key")
200
170
  end
201
- rescue HTTParty::Error => e
202
- raise "GenderAPI Request failed: #{e.message}"
171
+
172
+ key
203
173
  end
204
174
 
205
- ##
206
- # Parse JSON response safely.
207
- #
208
- # @param body [String] JSON string.
209
- # @return [Hash] Parsed Hash.
210
- #
211
- def parse_json(body)
212
- JSON.parse(body)
175
+ def normalize_base_url(url)
176
+ raise ValidationError.new("base_url must be a String", field: "base_url") unless url.is_a?(String)
177
+
178
+ uri = begin
179
+ URI.parse(url.strip)
180
+ rescue URI::InvalidURIError
181
+ raise ValidationError.new("base_url is not a valid URL", field: "base_url")
182
+ end
183
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty? && uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil?
184
+ raise ValidationError.new("base_url must be an absolute http(s) URL without credentials, query or fragment",
185
+ field: "base_url")
186
+ end
187
+ if uri.scheme == "http" && !LOCAL_HOSTS.include?(uri.host.downcase)
188
+ raise ValidationError.new("base_url must use https (plain http is only allowed for localhost tests)",
189
+ field: "base_url")
190
+ end
191
+
192
+ uri.path = uri.path.sub(%r{/+\z}, "")
193
+ [uri.to_s, uri]
194
+ end
195
+
196
+ def request(method, path, auth:, body: nil)
197
+ uri = @base_uri.dup
198
+ uri.path = "#{@base_uri.path}#{path}"
199
+ uri.path = "/" if uri.path.empty?
200
+ req = method == :post ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
201
+ req["Accept"] = ACCEPT
202
+ req["User-Agent"] = @user_agent
203
+ req["Authorization"] = "Bearer #{@api_key}" if auth && @api_key
204
+ if body
205
+ req["Content-Type"] = "application/json"
206
+ req.body = JSON.generate(body)
207
+ end
208
+
209
+ response = perform(uri, req)
210
+ handle(response, auth: auth) { |raw, ctx| yield raw, ctx }
211
+ end
212
+
213
+ def perform(uri, req)
214
+ http = Net::HTTP.new(uri.hostname, uri.port)
215
+ http.use_ssl = uri.scheme == "https"
216
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
217
+ http.open_timeout = @timeout
218
+ http.read_timeout = @timeout
219
+ http.write_timeout = @timeout
220
+ http.ssl_timeout = @timeout
221
+ http.max_retries = 0 # Net::HTTP would otherwise retry idempotent requests once.
222
+ http.start { |conn| conn.request(req) }
223
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, ::Timeout::Error => e
224
+ raise TimeoutError.new(original: e)
225
+ rescue SystemCallError, IOError, SocketError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse,
226
+ Net::ProtocolError, EOFError, Zlib::Error => e
227
+ raise TransportError.new(original: e)
228
+ end
229
+
230
+ def handle(response, auth:)
231
+ status = response.code.to_i
232
+ headers = response.each_header.to_h { |k, v| [k.downcase, v] }
233
+ raw_body = response.body
234
+ if status.between?(300, 399)
235
+ raise RedirectError.new(status: status, headers: headers, raw_body: raw_body)
236
+ end
237
+
238
+ parsed = parse_json(raw_body, headers)
239
+ if status >= 400
240
+ raise GenderAPI.api_error_class(status).new(status: status, headers: headers, body: parsed, raw_body: raw_body)
241
+ end
242
+ unless status.between?(200, 299)
243
+ raise InvalidResponseError.new("Unexpected HTTP #{status}; completion and billing are unknown.",
244
+ status: status, headers: headers, raw_body: raw_body)
245
+ end
246
+ unless parsed.is_a?(Hash)
247
+ raise InvalidResponseError.new("Expected a JSON object; completion and billing are unknown. " \
248
+ "Check usage before another submission.",
249
+ status: status, headers: headers, raw_body: raw_body)
250
+ end
251
+
252
+ ctx = { status: status, headers: headers, raw_body: raw_body }
253
+ result = yield parsed, ctx
254
+ check_access(result, ctx) if auth
255
+ result
256
+ end
257
+
258
+ def parse_json(raw_body, headers)
259
+ return nil if raw_body.nil? || raw_body.empty?
260
+ return nil unless headers["content-type"].to_s.downcase.include?("json")
261
+
262
+ text = raw_body.dup.force_encoding(Encoding::UTF_8)
263
+ return nil unless text.valid_encoding?
264
+
265
+ JSON.parse(text)
266
+ rescue JSON::ParserError
267
+ nil
268
+ end
269
+
270
+ def check_access(result, ctx)
271
+ return unless @api_key && @require_api_key_access && result.is_a?(Result)
272
+
273
+ mode = result.meta.access&.mode
274
+ return if mode.nil? || mode == "api_key"
275
+
276
+ raise UnexpectedAccessModeError.new(result: result, **ctx)
277
+ end
278
+
279
+ def invalid!(message, raw, ctx)
280
+ raise InvalidResponseError.new("#{message} Keep the request ID and check usage before another submission.",
281
+ body: raw, **ctx)
282
+ end
283
+
284
+ def check_envelope(raw, ctx)
285
+ invalid!("Response is missing data.", raw, ctx) unless raw.key?("data")
286
+ invalid!("Response is missing meta.", raw, ctx) unless raw["meta"].is_a?(Hash)
287
+ true
288
+ end
289
+
290
+ def check_prediction(raw, ctx)
291
+ check_envelope(raw, ctx)
292
+ invalid!("Response data is not a prediction object.", raw, ctx) unless raw["data"].is_a?(Hash)
293
+ true
294
+ end
295
+
296
+ # Structural checks only: every submitted item must come back once, in
297
+ # order, with its id, and with exactly one of data or error.
298
+ def check_batch(raw, ctx, wire)
299
+ check_envelope(raw, ctx)
300
+ rows = raw["data"]
301
+ unless rows.is_a?(Array) && rows.length == wire.length
302
+ invalid!("Batch result count does not match the submitted items.", raw, ctx)
303
+ end
304
+
305
+ rows.each_with_index do |row, index|
306
+ unless row.is_a?(Hash) && row["index"] == index && row["id"] == wire[index]["id"]
307
+ invalid!("Batch result mapping does not match the submitted items.", raw, ctx)
308
+ end
309
+ invalid!("Batch item must have exactly one of data or error.", raw, ctx) if row.key?("data") == row.key?("error")
310
+ end
311
+ true
213
312
  end
214
313
  end
215
314
  end