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.
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenderAPI
4
+ # Base class for every error raised by this library.
5
+ #
6
+ # Error messages never contain the API key or the submitted input values.
7
+ # The parsed body (+body+) can contain submitted inputs: inspect it securely
8
+ # and do not log it wholesale.
9
+ class Error < StandardError; end
10
+
11
+ # Invalid client input or configuration. Raised before any network request.
12
+ class ValidationError < Error
13
+ # @return [String, nil] the offending argument or wire field, e.g. "value" or "items[2].country"
14
+ attr_reader :field
15
+
16
+ def initialize(message, field: nil)
17
+ super(message)
18
+ @field = field
19
+ end
20
+ end
21
+
22
+ # No usable HTTP response (connection failure, TLS error, reset, timeout).
23
+ # The request may still have completed and been billed; check {Client#usage}
24
+ # before sending another prediction. The client never retries automatically.
25
+ class TransportError < Error
26
+ # @return [Exception, nil] the underlying low-level exception
27
+ attr_reader :original
28
+
29
+ def initialize(message = "Transport failed; completion and billing are unknown. Do not retry automatically.", original: nil)
30
+ super(message)
31
+ @original = original
32
+ end
33
+ end
34
+
35
+ # The configured timeout elapsed while connecting, writing or reading.
36
+ class TimeoutError < TransportError
37
+ def initialize(message = "Request timed out; completion and billing are unknown. Do not retry automatically.", original: nil)
38
+ super(message, original: original)
39
+ end
40
+ end
41
+
42
+ # Shared readers for errors that carry an HTTP response.
43
+ module ResponseContext
44
+ # @return [Integer, nil] HTTP status
45
+ attr_reader :status
46
+ # @return [String, nil] meta.request_id, body request_id, or the X-Request-ID header
47
+ attr_reader :request_id
48
+ # @return [Integer, String, nil] Retry-After in seconds (Integer) or the raw header value (HTTP date)
49
+ attr_reader :retry_after
50
+ # @return [Hash, nil] parsed JSON body (string keys), when the body was a JSON object
51
+ attr_reader :body
52
+ # @return [String, nil] raw response body
53
+ attr_reader :raw_body
54
+ # @return [Hash{String=>String}] response headers with lower-case names
55
+ attr_reader :headers
56
+
57
+ private
58
+
59
+ def assign_context(status:, body:, raw_body:, headers:)
60
+ @status = status
61
+ @body = body.is_a?(Hash) ? body : nil
62
+ @raw_body = raw_body
63
+ @headers = headers || {}
64
+ meta = @body && @body["meta"].is_a?(Hash) ? @body["meta"] : {}
65
+ @request_id = [meta["request_id"], @body && @body["request_id"], @headers["x-request-id"]]
66
+ .find { |v| v.is_a?(String) && !v.empty? }
67
+ @retry_after = ResponseContext.parse_retry_after(@headers["retry-after"])
68
+ end
69
+
70
+ # @api private
71
+ def self.parse_retry_after(value)
72
+ return nil if value.nil? || value.strip.empty?
73
+
74
+ value.strip.match?(/\A\d+\z/) ? value.strip.to_i : value.strip
75
+ end
76
+ end
77
+
78
+ # The server answered with a redirect (3xx). Redirects are never followed so
79
+ # the Bearer key is not forwarded to another location.
80
+ class RedirectError < Error
81
+ include ResponseContext
82
+
83
+ # @return [String, nil] the Location header that was not followed
84
+ attr_reader :location
85
+
86
+ def initialize(status:, headers:, raw_body: nil)
87
+ super("GenderAPI answered HTTP #{status} with a redirect; redirects are not followed. Check base_url.")
88
+ assign_context(status: status, body: nil, raw_body: raw_body, headers: headers)
89
+ @location = @headers["location"]
90
+ end
91
+ end
92
+
93
+ # A 2xx response that is not the expected JSON structure. Billing is unknown:
94
+ # keep {#request_id} and check {Client#usage} before another submission.
95
+ class InvalidResponseError < Error
96
+ include ResponseContext
97
+
98
+ def initialize(message, status:, headers:, body: nil, raw_body: nil)
99
+ super(message)
100
+ assign_context(status: status, body: body, raw_body: raw_body, headers: headers)
101
+ end
102
+ end
103
+
104
+ # An authenticated request was answered through IP-trial (or unauthenticated)
105
+ # access, usually because the configured key is invalid or unknown. The request
106
+ # has already run and may have consumed shared IP-trial credits; the full result
107
+ # is available in {#result}. Disable with +require_api_key_access: false+.
108
+ class UnexpectedAccessModeError < Error
109
+ include ResponseContext
110
+
111
+ # @return [GenderAPI::Result] the complete (already billed) result
112
+ attr_reader :result
113
+
114
+ # @return [String, nil] meta.access.mode, e.g. "ip_trial"
115
+ attr_reader :access_mode
116
+
117
+ # @return [String, nil] meta.access.reason, e.g. "api_key_invalid"
118
+ attr_reader :access_reason
119
+
120
+ def initialize(result:, status:, headers:, raw_body: nil)
121
+ access = result.meta.access
122
+ @access_mode = access&.mode
123
+ @access_reason = access&.reason
124
+ super("Expected API-key access but the response reports access mode #{@access_mode.inspect} " \
125
+ "(#{@access_reason.inspect}). Check your API key; this request may have consumed IP-trial credits.")
126
+ @result = result
127
+ assign_context(status: status, body: result.to_h, raw_body: raw_body, headers: headers)
128
+ end
129
+ end
130
+
131
+ # HTTP status >= 400. Exposes RFC 9457 Problem Details fields and the stable
132
+ # machine-readable {#code}. Match on {#code}, never on {#detail} text.
133
+ #
134
+ # Proxy-level errors can be non-JSON; then {#code} is nil and {#raw_body} is set.
135
+ class APIError < Error
136
+ include ResponseContext
137
+
138
+ # @return [String, nil] stable machine code, e.g. "insufficient_credits"
139
+ attr_reader :code
140
+ # @return [String, nil]
141
+ attr_reader :title
142
+ # @return [String, nil] human-readable detail (do not match on it)
143
+ attr_reader :detail
144
+ # @return [String, nil] problem type URI
145
+ attr_reader :type
146
+ # @return [String, nil] problem instance
147
+ attr_reader :instance
148
+ # @return [String, nil] recommended action, e.g. "wait_then_retry"
149
+ attr_reader :action
150
+ # @return [String, nil] link to the public error catalog
151
+ attr_reader :documentation
152
+ # @return [Array<Hash>] validation pointers: [{"pointer" => "/value", "message" => "..."}]
153
+ attr_reader :errors
154
+ # @return [GenderAPI::Usage, nil] meta.usage
155
+ attr_reader :usage
156
+ # @return [Array<GenderAPI::BatchItem>, nil] item outcomes of an all-failed batch (body "data")
157
+ attr_reader :items
158
+
159
+ def initialize(status:, headers:, body: nil, raw_body: nil)
160
+ assign_context(status: status, body: body, raw_body: raw_body, headers: headers)
161
+ b = @body || {}
162
+ @code = str(b["code"])
163
+ @title = str(b["title"])
164
+ @detail = str(b["detail"])
165
+ @type = str(b["type"])
166
+ @instance = str(b["instance"])
167
+ @action = str(b["action"])
168
+ @documentation = str(b["documentation"])
169
+ @errors = b["errors"].is_a?(Array) ? b["errors"] : []
170
+ meta = b["meta"].is_a?(Hash) ? b["meta"] : {}
171
+ @usage = meta["usage"].is_a?(Hash) ? Usage.new(meta["usage"]) : nil
172
+ @items = b["data"].is_a?(Array) ? b["data"].select { |r| r.is_a?(Hash) }.map { |r| BatchItem.new(r) } : nil
173
+ super(build_message)
174
+ end
175
+
176
+ # @return [String, nil] meta.usage.billing_status: "not_charged", "confirmed" or "unconfirmed"
177
+ def billing_status
178
+ @usage&.billing_status
179
+ end
180
+
181
+ # @return [Array<Hash>, nil] body "data" (all-failed batch rows) as raw hashes
182
+ def data
183
+ @body && @body["data"]
184
+ end
185
+
186
+ # True when the charge could not be confirmed; contact support with
187
+ # {#request_id} instead of retrying.
188
+ def billing_unconfirmed?
189
+ billing_status == "unconfirmed"
190
+ end
191
+
192
+ private
193
+
194
+ def str(value)
195
+ value.is_a?(String) ? value : nil
196
+ end
197
+
198
+ def build_message
199
+ parts = ["GenderAPI HTTP #{@status}"]
200
+ parts << @code if @code
201
+ parts << "action=#{@action}" if @action
202
+ parts << "billing_status=#{billing_status}" if billing_status
203
+ parts << "request_id=#{@request_id}" if @request_id
204
+ parts.join(" ")
205
+ end
206
+ end
207
+
208
+ # HTTP 400 (malformed request).
209
+ class BadRequestError < APIError; end
210
+ # HTTP 401 (invalid credentials).
211
+ class AuthenticationError < APIError; end
212
+ # HTTP 403 (access denied, disabled/expired key, insufficient credits).
213
+ class PermissionDeniedError < APIError; end
214
+ # HTTP 404.
215
+ class NotFoundError < APIError; end
216
+ # HTTP 422 (server-side validation, including the IP-trial batch limit). See {#errors}.
217
+ class UnprocessableEntityError < APIError; end
218
+ # HTTP 429. Wait {#retry_after} before another request; a retry is a new, billable operation.
219
+ class RateLimitError < APIError; end
220
+ # HTTP 5xx. Inspect {#billing_status} before sending another request.
221
+ class ServerError < APIError; end
222
+
223
+ # @api private
224
+ def self.api_error_class(status)
225
+ case status
226
+ when 400 then BadRequestError
227
+ when 401 then AuthenticationError
228
+ when 403 then PermissionDeniedError
229
+ when 404 then NotFoundError
230
+ when 422 then UnprocessableEntityError
231
+ when 429 then RateLimitError
232
+ when 500..599 then ServerError
233
+ else APIError
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenderAPI
4
+ # Read-only wrapper around a parsed JSON object (string keys).
5
+ #
6
+ # Every field of the response is kept, including fields this version of the
7
+ # library does not know about: use {#[]}, {#dig} or {#to_h} for them. Values are
8
+ # exactly as returned by the API (nulls stay nil; confidence is never rescaled).
9
+ class Model
10
+ # @api private
11
+ def self.fields(*names)
12
+ names.each do |name|
13
+ define_method(name) { @raw[name.to_s] }
14
+ end
15
+ end
16
+
17
+ # @param raw [Hash] parsed JSON object
18
+ def initialize(raw)
19
+ @raw = raw.is_a?(Hash) ? raw : {}
20
+ end
21
+
22
+ # @param key [String, Symbol]
23
+ def [](key)
24
+ @raw[key.to_s]
25
+ end
26
+
27
+ def dig(*keys)
28
+ @raw.dig(*keys.map { |k| k.is_a?(Symbol) ? k.to_s : k })
29
+ end
30
+
31
+ def key?(key)
32
+ @raw.key?(key.to_s)
33
+ end
34
+
35
+ # @return [Hash] the original parsed JSON object (string keys)
36
+ def to_h
37
+ @raw
38
+ end
39
+
40
+ def ==(other)
41
+ other.is_a?(self.class) && other.to_h == @raw
42
+ end
43
+ alias eql? ==
44
+
45
+ def hash
46
+ @raw.hash
47
+ end
48
+
49
+ def inspect
50
+ "#<#{self.class.name} #{@raw.inspect}>"
51
+ end
52
+
53
+ private
54
+
55
+ def model(key, klass)
56
+ value = @raw[key]
57
+ value.is_a?(Hash) ? klass.new(value) : nil
58
+ end
59
+ end
60
+
61
+ # meta.access: how the request was authorised.
62
+ class Access < Model
63
+ # mode: "api_key", "ip_trial" or "unauthenticated"
64
+ # reason: nil, "api_key_missing", "api_key_invalid" or "api_key_not_found"
65
+ fields :mode, :reason
66
+
67
+ def api_key?
68
+ mode == "api_key"
69
+ end
70
+
71
+ def ip_trial?
72
+ mode == "ip_trial"
73
+ end
74
+ end
75
+
76
+ # meta.usage: billing of this operation.
77
+ #
78
+ # * billing_status "not_charged": charged_credits is 0.
79
+ # * "confirmed": the net charge is known (a confirmed refund can make it 0).
80
+ # * "unconfirmed": charged_credits and remaining_credits are nil; contact
81
+ # support with the request ID before retrying.
82
+ #
83
+ # remaining_credits is the balance at completion; it can be negative or nil.
84
+ # resets_at, limit and period_seconds describe the IP trial (nil otherwise).
85
+ class Usage < Model
86
+ fields :billing_status, :charged_credits, :remaining_credits, :resets_at, :limit, :period_seconds
87
+
88
+ def confirmed?
89
+ billing_status == "confirmed"
90
+ end
91
+
92
+ def unconfirmed?
93
+ billing_status == "unconfirmed"
94
+ end
95
+
96
+ def not_charged?
97
+ billing_status == "not_charged"
98
+ end
99
+ end
100
+
101
+ # meta.summary of a batch: total = succeeded + failed; succeeded = identified + unknown.
102
+ class BatchSummary < Model
103
+ fields :total, :succeeded, :identified, :unknown, :failed
104
+ end
105
+
106
+ # Response metadata.
107
+ class Meta < Model
108
+ fields :request_id, :duration_ms
109
+
110
+ # @return [Access, nil]
111
+ def access
112
+ model("access", Access)
113
+ end
114
+
115
+ # @return [Usage, nil]
116
+ def usage
117
+ model("usage", Usage)
118
+ end
119
+
120
+ # @return [BatchSummary, nil] present on batch responses
121
+ def summary
122
+ model("summary", BatchSummary)
123
+ end
124
+ end
125
+
126
+ # A gender inference. It is an inference, not verification of identity, and it
127
+ # can be unknown (gender nil). An unknown result is a successful, billed outcome.
128
+ #
129
+ # * result_status: "identified" or "unknown"
130
+ # * reason: nil, "not_found", "no_name_candidate", "ambiguous" or "insufficient_evidence"
131
+ # * confidence: 0..1 or nil. Not a calibrated probability; interpret it with confidence_kind
132
+ # ("observed_frequency" for dataset results, "model_reported" for AI results).
133
+ # * sample_count: dataset sample count, nil for AI
134
+ # * source: "dataset", "ai" or "none"
135
+ # * country_source: nil, "dataset" or "ai_association" (never nationality or residence)
136
+ # * match: {"name", "method", "scope", "country"} (see {#match})
137
+ class Prediction < Model
138
+ fields :input, :name, :gender, :country, :confidence, :confidence_kind, :sample_count,
139
+ :source, :result_status, :reason, :country_source, :match
140
+
141
+ def identified?
142
+ result_status == "identified"
143
+ end
144
+
145
+ def unknown?
146
+ result_status == "unknown"
147
+ end
148
+ end
149
+
150
+ # Problem details for a single failed batch item.
151
+ class ItemError < Model
152
+ fields :type, :title, :status, :detail, :instance, :code, :request_id, :documentation, :action, :errors
153
+ end
154
+
155
+ # One row of a batch response. Exactly one of {#data} or {#error} is present.
156
+ class BatchItem < Model
157
+ fields :index, :id, :charged_credits
158
+
159
+ # @return [Prediction, nil]
160
+ def data
161
+ model("data", Prediction)
162
+ end
163
+
164
+ # @return [ItemError, nil]
165
+ def error
166
+ model("error", ItemError)
167
+ end
168
+
169
+ def success?
170
+ @raw.key?("data") && !@raw.key?("error")
171
+ end
172
+
173
+ def failed?
174
+ @raw.key?("error")
175
+ end
176
+ end
177
+
178
+ # Base class for every successful response: {"data" => ..., "meta" => {...}}.
179
+ class Result < Model
180
+ # @return [Meta]
181
+ def meta
182
+ Meta.new(@raw["meta"])
183
+ end
184
+
185
+ # @return [Usage, nil]
186
+ def usage
187
+ meta.usage
188
+ end
189
+
190
+ # @return [Access, nil]
191
+ def access
192
+ meta.access
193
+ end
194
+
195
+ # @return [String, nil]
196
+ def request_id
197
+ meta.request_id
198
+ end
199
+
200
+ # @return [Object] raw "data" value
201
+ def data
202
+ @raw["data"]
203
+ end
204
+ end
205
+
206
+ # Result of {Client#gender}, {Client#name}, {Client#email} and {Client#username}.
207
+ class GenderResult < Result
208
+ # @return [Prediction]
209
+ def data
210
+ Prediction.new(@raw["data"])
211
+ end
212
+ alias prediction data
213
+ end
214
+
215
+ # Result of {Client#gender_batch}. HTTP 200 can contain failed items; they
216
+ # are not raised. Retry only failed items, and only after billing is confirmed:
217
+ # resubmitting successful items charges them again.
218
+ class BatchResult < Result
219
+ # @return [Array<BatchItem>] in submission order
220
+ def data
221
+ Array(@raw["data"]).map { |row| BatchItem.new(row) }
222
+ end
223
+ alias items data
224
+
225
+ # @return [Array<BatchItem>]
226
+ def succeeded_items
227
+ items.select(&:success?)
228
+ end
229
+
230
+ # @return [Array<BatchItem>]
231
+ def failed_items
232
+ items.select(&:failed?)
233
+ end
234
+
235
+ def partial_failure?
236
+ !failed_items.empty?
237
+ end
238
+
239
+ # @return [BatchSummary, nil]
240
+ def summary
241
+ meta.summary
242
+ end
243
+ end
244
+
245
+ # Data of GET /usage (free): remaining_credits, expires_at, resets_at, limit, period_seconds.
246
+ class UsageData < Model
247
+ fields :remaining_credits, :expires_at, :resets_at, :limit, :period_seconds
248
+ end
249
+
250
+ # Result of {Client#usage}.
251
+ class UsageResult < Result
252
+ # @return [UsageData]
253
+ def data
254
+ UsageData.new(@raw["data"])
255
+ end
256
+ end
257
+
258
+ # Data of POST /phone/validate. Checks number structure, not subscriber existence.
259
+ class PhoneValidation < Model
260
+ fields :valid, :possible, :e164, :country, :country_calling_code
261
+
262
+ def valid?
263
+ valid == true
264
+ end
265
+
266
+ def possible?
267
+ possible == true
268
+ end
269
+ end
270
+
271
+ # Result of {Client#validate_phone}.
272
+ class PhoneResult < Result
273
+ # @return [PhoneValidation]
274
+ def data
275
+ PhoneValidation.new(@raw["data"])
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenderAPI
4
+ # Client-side checks that mirror the V2 request schema where they are cheap
5
+ # and certain. The API remains authoritative (e.g. email syntax and ISO
6
+ # country membership are validated by the server and reported as HTTP 422).
7
+ #
8
+ # @api private
9
+ module Validation
10
+ TYPES = %w[name email username].freeze
11
+ AI_MODES = %w[off fallback always].freeze
12
+ MAX_VALUE_LENGTH = 254
13
+ MAX_ID_LENGTH = 64
14
+ MAX_BATCH_ITEMS = 50
15
+ CONTROL_CHARS = /[\x00-\x1f\x7f]/.freeze
16
+ # Like the schema pattern \S (ECMAScript \s includes Unicode spaces and U+FEFF).
17
+ NON_SPACE = /[^[:space:]\u{FEFF}]/.freeze
18
+ COUNTRY = /\A[A-Z]{2}\z/.freeze
19
+ PHONE = /\A\+?[0-9 ()-]+\z/.freeze
20
+
21
+ # Ruby-style and wire-style keys accepted for a batch item.
22
+ ITEM_KEYS = {
23
+ "type" => :type, "value" => :value, "country" => :country, "id" => :id,
24
+ "ai_mode" => :ai_mode, "force_to_genderize" => :force_to_genderize,
25
+ "forceToGenderize" => :force_to_genderize, "options" => :options
26
+ }.freeze
27
+
28
+ module_function
29
+
30
+ # Build and validate the exact wire body for one prediction.
31
+ # @return [Hash] string-keyed wire body
32
+ def gender_item(type, value, country: nil, ai_mode: nil, force_to_genderize: nil, id: nil, field: nil)
33
+ prefix = field ? "#{field}." : ""
34
+ type = type.to_s if type.is_a?(Symbol)
35
+ unless TYPES.include?(type)
36
+ raise ValidationError.new("type must be one of: #{TYPES.join(', ')}", field: "#{prefix}type")
37
+ end
38
+
39
+ value = check_value(value, "#{prefix}value")
40
+ ai_mode = ai_mode.to_s if ai_mode.is_a?(Symbol)
41
+ unless ai_mode.nil? || AI_MODES.include?(ai_mode)
42
+ raise ValidationError.new("ai_mode must be one of: #{AI_MODES.join(', ')}", field: "#{prefix}options.ai_mode")
43
+ end
44
+ unless force_to_genderize.nil? || force_to_genderize == true || force_to_genderize == false
45
+ raise ValidationError.new("force_to_genderize must be true or false", field: "#{prefix}forceToGenderize")
46
+ end
47
+ if force_to_genderize == true && %w[off always].include?(ai_mode)
48
+ raise ValidationError.new("force_to_genderize cannot be combined with ai_mode off or always",
49
+ field: "#{prefix}forceToGenderize")
50
+ end
51
+ check_country(country, "#{prefix}country") unless country.nil?
52
+ check_id(id, "#{prefix}id") unless id.nil?
53
+
54
+ body = { "type" => type, "value" => value }
55
+ body["country"] = country unless country.nil?
56
+ body["id"] = id unless id.nil?
57
+ body["forceToGenderize"] = true if force_to_genderize == true
58
+ body["options"] = { "ai_mode" => ai_mode } unless ai_mode.nil?
59
+ body
60
+ end
61
+
62
+ # Normalise and validate a batch.
63
+ # @return [Array<Hash>] string-keyed wire items
64
+ def batch_items(items)
65
+ unless items.is_a?(Array) && items.length.between?(1, MAX_BATCH_ITEMS)
66
+ raise ValidationError.new("items must be an Array of 1-#{MAX_BATCH_ITEMS} items; split larger jobs yourself",
67
+ field: "items")
68
+ end
69
+
70
+ wire = items.each_with_index.map { |item, index| batch_item(item, "items[#{index}]") }
71
+ ids = wire.map { |w| w["id"] }.compact
72
+ raise ValidationError.new("batch item ids must be unique", field: "items") if ids.uniq.length != ids.length
73
+
74
+ wire
75
+ end
76
+
77
+ def batch_item(item, field)
78
+ raise ValidationError.new("each batch item must be a Hash", field: field) unless item.is_a?(Hash)
79
+
80
+ args = {}
81
+ item.each do |key, val|
82
+ name = ITEM_KEYS[key.to_s]
83
+ raise ValidationError.new("unsupported batch item field #{key.to_s.inspect}", field: field) if name.nil?
84
+ if args.key?(name)
85
+ raise ValidationError.new("batch item field #{key.to_s.inspect} is given twice", field: field)
86
+ end
87
+
88
+ args[name] = val
89
+ end
90
+ if args.key?(:options)
91
+ options = args.delete(:options)
92
+ unless options.is_a?(Hash) && options.keys.map(&:to_s) - ["ai_mode"] == []
93
+ raise ValidationError.new("options may only contain ai_mode", field: "#{field}.options")
94
+ end
95
+ if args.key?(:ai_mode)
96
+ raise ValidationError.new("give ai_mode either at top level or in options", field: "#{field}.options")
97
+ end
98
+
99
+ args[:ai_mode] = options["ai_mode"] || options[:ai_mode]
100
+ end
101
+ gender_item(args.delete(:type), args.delete(:value), **args, field: field)
102
+ end
103
+
104
+ # @return [Hash] string-keyed wire body for phone validation
105
+ def phone(number, country)
106
+ unless number.is_a?(String) && number.length.between?(3, 32) && number.match?(PHONE)
107
+ raise ValidationError.new("number must be 3-32 characters of digits, spaces, parentheses or hyphens, " \
108
+ "optionally starting with +", field: "number")
109
+ end
110
+ if country.nil?
111
+ unless number.start_with?("+")
112
+ raise ValidationError.new("country is required unless number starts with +", field: "country")
113
+ end
114
+ else
115
+ check_country(country, "country")
116
+ end
117
+ body = { "number" => number }
118
+ body["country"] = country unless country.nil?
119
+ body
120
+ end
121
+
122
+ def check_value(value, field)
123
+ unless value.is_a?(String)
124
+ raise ValidationError.new("value must be a String", field: field)
125
+ end
126
+
127
+ value = if value.encoding == Encoding::BINARY
128
+ value.dup.force_encoding(Encoding::UTF_8)
129
+ elsif value.encoding != Encoding::UTF_8
130
+ value.encode(Encoding::UTF_8)
131
+ else
132
+ value
133
+ end
134
+ raise ValidationError.new("value must be valid UTF-8 text", field: field) unless value.valid_encoding?
135
+ if !value.match?(NON_SPACE) || value.length > MAX_VALUE_LENGTH || value.match?(CONTROL_CHARS)
136
+ raise ValidationError.new("value must contain 1-#{MAX_VALUE_LENGTH} characters, not only whitespace, " \
137
+ "and no control characters", field: field)
138
+ end
139
+
140
+ value
141
+ rescue EncodingError
142
+ raise ValidationError.new("value must be valid UTF-8 text", field: field)
143
+ end
144
+
145
+ def check_country(country, field)
146
+ return if country.is_a?(String) && country.match?(COUNTRY)
147
+
148
+ raise ValidationError.new("country must be an uppercase ISO 3166-1 alpha-2 code such as \"US\"; " \
149
+ "omit it when unknown", field: field)
150
+ end
151
+
152
+ def check_id(id, field)
153
+ return if id.is_a?(String) && id.length.between?(1, MAX_ID_LENGTH)
154
+
155
+ raise ValidationError.new("id must be a String of 1-#{MAX_ID_LENGTH} characters", field: field)
156
+ end
157
+ end
158
+ end
@@ -1,5 +1,5 @@
1
- # lib/genderapi/version.rb
1
+ # frozen_string_literal: true
2
2
 
3
3
  module GenderAPI
4
- VERSION = "1.0.5"
4
+ VERSION = "2.0.0"
5
5
  end
data/lib/genderapi.rb CHANGED
@@ -1,8 +1,14 @@
1
- # lib/genderapi.rb
1
+ # frozen_string_literal: true
2
2
 
3
+ # Official GenderAPI.io V2 client for Ruby.
4
+ #
5
+ # Requiring this file and constructing a client never makes a network request.
6
+ # Keep API keys on the server; never embed them in browser or mobile code.
3
7
  require "genderapi/version"
8
+ require "genderapi/errors"
9
+ require "genderapi/models"
10
+ require "genderapi/validation"
4
11
  require "genderapi/client"
5
12
 
6
13
  module GenderAPI
7
- # boş bırakabilirsin veya helper methodlar koyabilirsin
8
14
  end