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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +193 -219
- data/lib/genderapi/client.rb +279 -180
- data/lib/genderapi/errors.rb +236 -0
- data/lib/genderapi/models.rb +278 -0
- data/lib/genderapi/validation.rb +158 -0
- data/lib/genderapi/version.rb +2 -2
- data/lib/genderapi.rb +8 -2
- metadata +19 -56
|
@@ -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
|
data/lib/genderapi/version.rb
CHANGED
data/lib/genderapi.rb
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
#
|
|
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
|