fingerly 0.1.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 +7 -0
- data/LICENSE +202 -0
- data/README.md +402 -0
- data/lib/fingerly/client.rb +251 -0
- data/lib/fingerly/config.rb +146 -0
- data/lib/fingerly/errors.rb +95 -0
- data/lib/fingerly/models.rb +182 -0
- data/lib/fingerly/proxy.rb +430 -0
- data/lib/fingerly/redacted.rb +52 -0
- data/lib/fingerly/transport.rb +344 -0
- data/lib/fingerly/version.rb +6 -0
- data/lib/fingerly/webhooks.rb +126 -0
- data/lib/fingerly.rb +27 -0
- data/sig/fingerly.rbs +213 -0
- metadata +61 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Fingerly
|
|
8
|
+
# Reads events for one organization and environment.
|
|
9
|
+
#
|
|
10
|
+
# Build one and reuse it: it is safe to share between threads and fibers,
|
|
11
|
+
# and connections come from one pool per process. Secret keys and webhook
|
|
12
|
+
# secrets belong in server-side code only; a browser must never see either.
|
|
13
|
+
#
|
|
14
|
+
# fingerly = Fingerly::Client.new(secret_key: ENV.fetch("FINGERLY_SECRET_KEY"))
|
|
15
|
+
# event = fingerly.events.get(request_id)
|
|
16
|
+
class Client
|
|
17
|
+
include Redacted
|
|
18
|
+
|
|
19
|
+
DEFAULT_TIMEOUT = 10
|
|
20
|
+
DEFAULT_API_VERSION = "v1"
|
|
21
|
+
USER_AGENT = "fingerly-ruby/#{VERSION}".freeze
|
|
22
|
+
|
|
23
|
+
# An error body is small; anything larger is not one worth reading.
|
|
24
|
+
MAX_ERROR_BODY = 1 << 20
|
|
25
|
+
|
|
26
|
+
# @return [Events] event reads under this client's secret key
|
|
27
|
+
attr_reader :events
|
|
28
|
+
|
|
29
|
+
# @param secret_key [String] a server-only key,
|
|
30
|
+
# +fly_sk_{environment}_…+, whose prefix names the environment read
|
|
31
|
+
# @param endpoint [String, nil] overrides the Fingerly API origin, for a
|
|
32
|
+
# local or self-hosted API
|
|
33
|
+
# @param api_version [String] the API route version
|
|
34
|
+
# @param timeout [Numeric] the deadline for each request in seconds,
|
|
35
|
+
# covering the response body
|
|
36
|
+
# @param configure_http [Proc, nil] receives the +Net::HTTP+ before it
|
|
37
|
+
# connects, for a proxy or a custom TLS setup. Timeouts set there are
|
|
38
|
+
# replaced: the deadline belongs to this client.
|
|
39
|
+
# @raise [ConfigurationError] if a key, endpoint, version or timeout is
|
|
40
|
+
# invalid. Nothing is sent until then.
|
|
41
|
+
def initialize(secret_key:, endpoint: nil, api_version: DEFAULT_API_VERSION,
|
|
42
|
+
timeout: DEFAULT_TIMEOUT, configure_http: nil)
|
|
43
|
+
# The key is validated even when an endpoint is given, so a public, proxy
|
|
44
|
+
# or management key can never reach an API that would accept it.
|
|
45
|
+
Config.parse_key(secret_key, Config::SECRET)
|
|
46
|
+
origin = Config.normalize_endpoint(endpoint || Config::DEFAULT_ENDPOINT)
|
|
47
|
+
|
|
48
|
+
@secret_key = secret_key
|
|
49
|
+
@origin = origin
|
|
50
|
+
@base_path = "/api/#{Config.normalize_api_version(api_version)}"
|
|
51
|
+
@transport = Transport.new(
|
|
52
|
+
origin: origin,
|
|
53
|
+
timeout: Config.positive_seconds(timeout, "timeout"),
|
|
54
|
+
configure_http: configure_http
|
|
55
|
+
)
|
|
56
|
+
@events = Events.new(self)
|
|
57
|
+
freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Closes the idle connections to this client's destination, which every
|
|
61
|
+
# client and proxy in the process shares.
|
|
62
|
+
#
|
|
63
|
+
# Reusing the client afterwards simply opens another one, so this is for a
|
|
64
|
+
# script that is finished rather than something a server needs to call.
|
|
65
|
+
def close
|
|
66
|
+
@transport.close
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Performs one authenticated GET and returns the decoded body.
|
|
70
|
+
#
|
|
71
|
+
# Never retries a delivered request, and the deadline covers reading the
|
|
72
|
+
# body.
|
|
73
|
+
#
|
|
74
|
+
# @api private
|
|
75
|
+
def get(path)
|
|
76
|
+
result = @transport.call(
|
|
77
|
+
Net::HTTP::Get, @base_path + path,
|
|
78
|
+
"x-api-key" => @secret_key,
|
|
79
|
+
"accept" => "application/json",
|
|
80
|
+
"user-agent" => USER_AGENT
|
|
81
|
+
)
|
|
82
|
+
raise api_error(result) unless (200..299).cover?(result.status)
|
|
83
|
+
|
|
84
|
+
decode(result.body)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def redacted_fields
|
|
90
|
+
[["endpoint", @origin + @base_path], ["secret_key", Redacted::MARK]]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @raise [Error] if the body is not JSON, which means it did not come from
|
|
94
|
+
# the Fingerly API
|
|
95
|
+
def decode(body)
|
|
96
|
+
parsed = JSON.parse(body)
|
|
97
|
+
return parsed if parsed.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
raise Error, "Fingerly API returned a body that is not an object"
|
|
100
|
+
rescue JSON::ParserError
|
|
101
|
+
raise Error, "Fingerly API returned a body that is not JSON"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Maps a failed response to a redacted APIError.
|
|
105
|
+
#
|
|
106
|
+
# Only the status and the structured fields the API documents are
|
|
107
|
+
# surfaced. The raw body is deliberately dropped rather than echoed.
|
|
108
|
+
def api_error(result)
|
|
109
|
+
envelope = error_envelope(result.body)
|
|
110
|
+
error = envelope["error"]
|
|
111
|
+
error = {} unless error.is_a?(Hash)
|
|
112
|
+
|
|
113
|
+
APIError.new(
|
|
114
|
+
status: result.status,
|
|
115
|
+
code: redact(error["code"]),
|
|
116
|
+
# Redacting the rendered JSON covers nested values without walking the
|
|
117
|
+
# tree.
|
|
118
|
+
details: error.key?("details") ? redact_tree(error["details"]) : nil,
|
|
119
|
+
request_id: redact(envelope["request_id"] || header(result, "x-request-id")),
|
|
120
|
+
retry_after: retry_after(header(result, "retry-after"))
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def header(result, name)
|
|
125
|
+
value = Array(result.headers[name]).first
|
|
126
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Retry-After is either whole seconds or an HTTP date.
|
|
130
|
+
#
|
|
131
|
+
# @return [Integer, nil] seconds from now, never negative
|
|
132
|
+
def retry_after(value)
|
|
133
|
+
return nil if value.nil?
|
|
134
|
+
return Integer(value, 10) if value.match?(/\A\d{1,10}\z/)
|
|
135
|
+
|
|
136
|
+
[(Time.httpdate(value) - Time.now).ceil, 0].max
|
|
137
|
+
rescue ArgumentError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def error_envelope(body)
|
|
142
|
+
return {} if body.empty? || body.bytesize > MAX_ERROR_BODY
|
|
143
|
+
|
|
144
|
+
parsed = JSON.parse(body)
|
|
145
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
146
|
+
rescue JSON::ParserError
|
|
147
|
+
# A non-JSON body still reports its HTTP status.
|
|
148
|
+
{}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def redact(value)
|
|
152
|
+
value.is_a?(String) ? value.gsub(@secret_key, "[redacted]") : value
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def redact_tree(value)
|
|
156
|
+
return nil if value.nil?
|
|
157
|
+
|
|
158
|
+
# Any JSON value is legal at the top level, so a details field that is a
|
|
159
|
+
# bare string or number survives the round trip too. quirks_mode is not
|
|
160
|
+
# passed: the json gem dropped the keyword in 3.0, and it has not been
|
|
161
|
+
# needed since 2.0.
|
|
162
|
+
JSON.parse(JSON.generate(value).gsub(@secret_key, "[redacted]"))
|
|
163
|
+
rescue JSON::ParserError, JSON::GeneratorError
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Reads events stored for the organization and environment of the client's
|
|
169
|
+
# secret key. Reached as +fingerly.events+.
|
|
170
|
+
class Events
|
|
171
|
+
include Redacted
|
|
172
|
+
|
|
173
|
+
# @api private
|
|
174
|
+
def initialize(client)
|
|
175
|
+
@client = client
|
|
176
|
+
freeze
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Fetches one event by request id.
|
|
180
|
+
#
|
|
181
|
+
# @param request_id [String] the +request_id+ returned by identification
|
|
182
|
+
# @return [Event]
|
|
183
|
+
# @raise [APIError] on any non-2xx response. +404 event_pending+
|
|
184
|
+
# ({APIError#pending?}) means the event was accepted and is still being
|
|
185
|
+
# recorded: retry after {APIError#retry_after} seconds.
|
|
186
|
+
# +404 event_not_found+ means a request id that was never issued, or one
|
|
187
|
+
# past retention.
|
|
188
|
+
# @raise [TimeoutError] if the request exceeds the client timeout
|
|
189
|
+
def get(request_id)
|
|
190
|
+
raise ConfigurationError, "request_id is required" if request_id.to_s.empty?
|
|
191
|
+
|
|
192
|
+
Event.new(@client.get("/events/#{Events.escape(request_id)}"))
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Lists events within the retention window, newest first.
|
|
196
|
+
#
|
|
197
|
+
# An unset filter is not sent, so the server applies its own default.
|
|
198
|
+
#
|
|
199
|
+
# @param from [Time, nil] start of the window; defaults to 24 hours ago,
|
|
200
|
+
# and may be at most 30 days wide and start at most 30 days ago
|
|
201
|
+
# @param to [Time, nil] end of the window; defaults to now
|
|
202
|
+
# @param page [Integer, nil] one-based page number
|
|
203
|
+
# @param limit [Integer, nil] rows per page; the server caps it at 200
|
|
204
|
+
# @param visitor [String, nil] restricts the listing to one exact visitor
|
|
205
|
+
# @param level [String, nil] restricts it to +low+, +medium+ or +high+
|
|
206
|
+
# @return [EventPage]
|
|
207
|
+
# @raise [APIError] on any non-2xx response
|
|
208
|
+
def list(from: nil, to: nil, page: nil, limit: nil, visitor: nil, level: nil)
|
|
209
|
+
query = {
|
|
210
|
+
"from" => from && Events.instant(from),
|
|
211
|
+
"to" => to && Events.instant(to),
|
|
212
|
+
"page" => page && Config.positive_int(page, "page"),
|
|
213
|
+
"limit" => limit && Config.positive_int(limit, "limit"),
|
|
214
|
+
"visitor" => visitor,
|
|
215
|
+
"level" => level
|
|
216
|
+
}.compact
|
|
217
|
+
|
|
218
|
+
suffix = query.empty? ? "" : "?#{URI.encode_www_form(query)}"
|
|
219
|
+
EventPage.new(@client.get("/events#{suffix}"))
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Percent-encodes a request id into exactly one path segment.
|
|
223
|
+
#
|
|
224
|
+
# Only RFC 3986 unreserved characters survive, so a slash cannot escape the
|
|
225
|
+
# segment and a space cannot become a +.
|
|
226
|
+
#
|
|
227
|
+
# @api private
|
|
228
|
+
def self.escape(value)
|
|
229
|
+
# Byte by byte, so an id that is not valid UTF-8 is escaped like any
|
|
230
|
+
# other rather than raising from the regular expression.
|
|
231
|
+
value.to_s.b.gsub(/[^A-Za-z0-9\-._~]/n) { |byte| format("%%%02X", byte.ord) }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# The API accepts RFC 3339 only, and reads it as an absolute instant.
|
|
235
|
+
#
|
|
236
|
+
# @api private
|
|
237
|
+
def self.instant(value)
|
|
238
|
+
raise ConfigurationError, "from and to must be Time values" unless value.is_a?(Time)
|
|
239
|
+
|
|
240
|
+
# getutc, because Time#utc converts the caller's own object in place and
|
|
241
|
+
# raises on a frozen one.
|
|
242
|
+
value.getutc.iso8601(3)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
private
|
|
246
|
+
|
|
247
|
+
def redacted_fields
|
|
248
|
+
[]
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Fingerly
|
|
7
|
+
# Validation of caller-supplied configuration.
|
|
8
|
+
#
|
|
9
|
+
# Everything here runs before any network access, so a misconfigured client
|
|
10
|
+
# fails while it is being built rather than mid-request or against the wrong
|
|
11
|
+
# host.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
module Config
|
|
15
|
+
# Key kinds Fingerly issues: browser public, server secret, and proxy.
|
|
16
|
+
PUBLIC = "pk"
|
|
17
|
+
SECRET = "sk"
|
|
18
|
+
PROXY = "px"
|
|
19
|
+
|
|
20
|
+
# Anchored with \A and \z rather than ^ and $, which in Ruby match at every
|
|
21
|
+
# line boundary. A key with a newline in it would otherwise pass.
|
|
22
|
+
KEY = /\Afly_(pk|sk|px)_(production|staging|development)_[A-Za-z0-9_-]+\z/
|
|
23
|
+
API_VERSION = /\Av[1-9][0-9]*\z/
|
|
24
|
+
|
|
25
|
+
LABELS = { PUBLIC => "public", SECRET => "secret", PROXY => "proxy" }.freeze
|
|
26
|
+
|
|
27
|
+
# The one Fingerly API origin.
|
|
28
|
+
DEFAULT_ENDPOINT = "https://api.fingerly.io"
|
|
29
|
+
|
|
30
|
+
# The environment a Fingerly key is scoped to.
|
|
31
|
+
Scope = Struct.new(:environment)
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# Parses a Fingerly key and asserts it is of the expected kind.
|
|
36
|
+
#
|
|
37
|
+
# @param key [String, nil] the raw key, such as +fly_sk_production_…+
|
|
38
|
+
# @param expected [String] the kind the caller requires
|
|
39
|
+
# @return [Scope] the environment the key is scoped to
|
|
40
|
+
# @raise [ConfigurationError] if the key is malformed or of another kind,
|
|
41
|
+
# so a public, proxy or management key can never reach an API that would
|
|
42
|
+
# accept it
|
|
43
|
+
def parse_key(key, expected)
|
|
44
|
+
scope = scope_for(key, expected)
|
|
45
|
+
return scope if scope
|
|
46
|
+
|
|
47
|
+
raise ConfigurationError,
|
|
48
|
+
"A valid Fingerly #{LABELS.fetch(expected)} key is required"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The same, without raising.
|
|
52
|
+
#
|
|
53
|
+
# @return [Scope, nil] nil when the key is not of the expected kind
|
|
54
|
+
def scope_for(key, expected)
|
|
55
|
+
# As bytes, because the proxy passes the browser's own header here and a
|
|
56
|
+
# string that is not valid in its encoding raises from a regular
|
|
57
|
+
# expression instead of failing to match.
|
|
58
|
+
match = key.is_a?(String) ? KEY.match(key.b) : nil
|
|
59
|
+
return nil unless match && match[1] == expected
|
|
60
|
+
|
|
61
|
+
Scope.new(match[2])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Validates a caller-supplied API origin.
|
|
65
|
+
#
|
|
66
|
+
# Credentials, queries and fragments are refused: they would be attached to
|
|
67
|
+
# every authenticated request, and embedded credentials would leak on a
|
|
68
|
+
# redirect.
|
|
69
|
+
#
|
|
70
|
+
# @return [String] the origin without trailing slashes
|
|
71
|
+
# @raise [ConfigurationError] if the URL is malformed or carries any of those
|
|
72
|
+
def normalize_endpoint(value)
|
|
73
|
+
uri = begin
|
|
74
|
+
URI.parse(value.to_s)
|
|
75
|
+
rescue URI::InvalidURIError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
usable = uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? &&
|
|
79
|
+
uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil?
|
|
80
|
+
unless usable
|
|
81
|
+
raise ConfigurationError,
|
|
82
|
+
"endpoint must be an HTTP(S) URL without credentials, query, or fragment"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
value.to_s.sub(%r{/+\z}, "")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Validates an API route version before it is spliced into a URL.
|
|
89
|
+
#
|
|
90
|
+
# @raise [ConfigurationError] if the version is not of that shape
|
|
91
|
+
def normalize_api_version(version)
|
|
92
|
+
return version if version.is_a?(String) && API_VERSION.match?(version)
|
|
93
|
+
|
|
94
|
+
raise ConfigurationError, %(api_version must be a version such as "v1" or "v2")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Validates a duration option, in seconds.
|
|
98
|
+
#
|
|
99
|
+
# @raise [ConfigurationError] if it is not a positive number
|
|
100
|
+
def positive_seconds(value, name)
|
|
101
|
+
return value if value.is_a?(Numeric) && value.positive?
|
|
102
|
+
|
|
103
|
+
raise ConfigurationError, "#{name} must be a positive number of seconds"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Validates a byte count option.
|
|
107
|
+
#
|
|
108
|
+
# @raise [ConfigurationError] if it is not a positive integer
|
|
109
|
+
def positive_int(value, name)
|
|
110
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
111
|
+
|
|
112
|
+
raise ConfigurationError, "#{name} must be a positive integer"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Validates the path the dashboard issued for a proxy and drops trailing
|
|
116
|
+
# slashes. It is matched exactly: the proxy answers that path and nothing
|
|
117
|
+
# under it.
|
|
118
|
+
def normalize_path(value)
|
|
119
|
+
path = value.to_s.strip.sub(%r{/+\z}, "")
|
|
120
|
+
segments = path.split("/", -1).drop(1)
|
|
121
|
+
usable = path.match?(%r{\A(/[A-Za-z0-9._~-]+)+\z}) &&
|
|
122
|
+
!segments.intersect?(%w[. ..])
|
|
123
|
+
raise ConfigurationError, "path must be an absolute URL path such as /k7x2qm4a" unless usable
|
|
124
|
+
|
|
125
|
+
path
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Whether a value is exactly one IPv4 or IPv6 address and nothing else: no
|
|
129
|
+
# list, port, prefix length, zone, padding or whitespace.
|
|
130
|
+
def ip_literal?(value)
|
|
131
|
+
return false unless value.is_a?(String) && value.bytesize <= 45
|
|
132
|
+
return false unless value.b.match?(/\A[0-9A-Fa-f:.]+\z/)
|
|
133
|
+
|
|
134
|
+
IPAddr.new(value)
|
|
135
|
+
true
|
|
136
|
+
rescue IPAddr::Error
|
|
137
|
+
false
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Whether a value is printable ASCII, so it cannot inject a header of its
|
|
141
|
+
# own.
|
|
142
|
+
def header_safe?(value)
|
|
143
|
+
value.is_a?(String) && value.b.match?(/\A[\x20-\x7e]*\z/)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fingerly
|
|
4
|
+
# Base class for every failure this SDK raises at run time.
|
|
5
|
+
#
|
|
6
|
+
# A bad option raises {ConfigurationError} instead, which is an
|
|
7
|
+
# +ArgumentError+: a key that was never valid is a programming mistake, not
|
|
8
|
+
# something a retry or a rescue around the call site can help with.
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
# Raised when a credential, route, limit or callback is unusable.
|
|
12
|
+
#
|
|
13
|
+
# It derives from +ArgumentError+, so ordinary Ruby handling applies, and it
|
|
14
|
+
# is raised while the client or proxy is being built, before any network
|
|
15
|
+
# access.
|
|
16
|
+
class ConfigurationError < ArgumentError; end
|
|
17
|
+
|
|
18
|
+
# Raised for any non-2xx response from the Fingerly API.
|
|
19
|
+
#
|
|
20
|
+
# The message carries only the HTTP status. Response bodies are never
|
|
21
|
+
# echoed, and any occurrence of the secret key in the metadata is redacted.
|
|
22
|
+
#
|
|
23
|
+
# begin
|
|
24
|
+
# event = fingerly.events.get(request_id)
|
|
25
|
+
# rescue Fingerly::APIError => e
|
|
26
|
+
# return "retry" if e.pending? # not recorded yet: retry after e.retry_after
|
|
27
|
+
# return "refuse" if e.event_not_found?
|
|
28
|
+
# raise
|
|
29
|
+
# end
|
|
30
|
+
class APIError < Error
|
|
31
|
+
# @return [Integer] the HTTP status code
|
|
32
|
+
attr_reader :status
|
|
33
|
+
|
|
34
|
+
# @return [String, nil] the stable machine-readable code, such as
|
|
35
|
+
# +event_not_found+
|
|
36
|
+
attr_reader :code
|
|
37
|
+
|
|
38
|
+
# @return [Object, nil] extra context from the error body; its shape
|
|
39
|
+
# depends on {#code}
|
|
40
|
+
attr_reader :details
|
|
41
|
+
|
|
42
|
+
# @return [String, nil] the server-side trace id to quote in support
|
|
43
|
+
# requests
|
|
44
|
+
attr_reader :request_id
|
|
45
|
+
|
|
46
|
+
# @return [Integer, nil] seconds to wait before trying again, from the
|
|
47
|
+
# +Retry-After+ header, or nil when the response carried none. Sent with
|
|
48
|
+
# 429, +404 event_pending+ and some 503 answers; the SDK never retries by
|
|
49
|
+
# itself.
|
|
50
|
+
attr_reader :retry_after
|
|
51
|
+
|
|
52
|
+
# @api private
|
|
53
|
+
def initialize(status:, code: nil, details: nil, request_id: nil, retry_after: nil)
|
|
54
|
+
@status = status
|
|
55
|
+
@code = code
|
|
56
|
+
@details = details
|
|
57
|
+
@request_id = request_id
|
|
58
|
+
@retry_after = retry_after
|
|
59
|
+
described = "Fingerly API returned HTTP #{status}"
|
|
60
|
+
super(code ? "#{described} (#{code})" : described)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Whether this is a read for an event that does not exist: a request id
|
|
64
|
+
# that was never issued, or one past retention.
|
|
65
|
+
#
|
|
66
|
+
# It is false for {#pending?}: that event exists and is still being
|
|
67
|
+
# recorded.
|
|
68
|
+
#
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def event_not_found?
|
|
71
|
+
status == 404 && code == "event_not_found"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Whether this is a read for an event the API accepted but has not
|
|
75
|
+
# recorded yet (+404 event_pending+).
|
|
76
|
+
#
|
|
77
|
+
# Nothing is decided yet: read again after {#retry_after} seconds, or
|
|
78
|
+
# verify asynchronously. The SDK never retries by itself.
|
|
79
|
+
#
|
|
80
|
+
# @return [Boolean]
|
|
81
|
+
def pending?
|
|
82
|
+
status == 404 && code == "event_pending"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Raised when a request does not finish within its time limit.
|
|
87
|
+
#
|
|
88
|
+
# The deadline covers reading the response body, not only the headers.
|
|
89
|
+
class TimeoutError < Error; end
|
|
90
|
+
|
|
91
|
+
# Raised when the Fingerly API could not be reached at all.
|
|
92
|
+
#
|
|
93
|
+
# A DNS failure, a refused connection or a TLS error arrives here.
|
|
94
|
+
class ConnectionError < Error; end
|
|
95
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Fingerly
|
|
6
|
+
# One signal group that contributed to an event's suspect score.
|
|
7
|
+
class EventTrigger
|
|
8
|
+
# @return [String, nil] the signal group, such as +vpn+ or +bot+
|
|
9
|
+
attr_reader :signal
|
|
10
|
+
# @return [Integer] the group's combined weight in this score
|
|
11
|
+
attr_reader :weight
|
|
12
|
+
# @return [String, nil] +low+, +medium+ or +high+
|
|
13
|
+
attr_reader :confidence
|
|
14
|
+
|
|
15
|
+
# @api private
|
|
16
|
+
def initialize(payload)
|
|
17
|
+
@signal = payload["signal"]
|
|
18
|
+
@weight = payload["weight"] || 0
|
|
19
|
+
@confidence = payload["confidence"]
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def inspect
|
|
24
|
+
"#<Fingerly::EventTrigger #{signal}=#{weight}>"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A stored identification event.
|
|
29
|
+
#
|
|
30
|
+
# {#suspect_score} is +nil+ when the request was not scored, which must not
|
|
31
|
+
# be read as zero: a visitor about whom nothing fired scores 0, while a
|
|
32
|
+
# submission that was never enriched has no score at all. {#scored?} states
|
|
33
|
+
# the distinction at the call site.
|
|
34
|
+
class Event
|
|
35
|
+
# Fields copied straight from the response, in the order the API documents
|
|
36
|
+
# them. Anything not listed stays reachable through {#raw}.
|
|
37
|
+
STRING_FIELDS = %w[
|
|
38
|
+
request_id visitor_id state environment platform sdk_platform sdk_version
|
|
39
|
+
origin url_host url_path tag ip_address country_code country_name asn
|
|
40
|
+
asn_name anonymity_network agent agent_version os device_kind visitor_kind
|
|
41
|
+
suspect_level
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
STRING_FIELDS.each { |name| attr_reader name.to_sym }
|
|
45
|
+
|
|
46
|
+
# @return [Time] when the request arrived
|
|
47
|
+
attr_reader :occurred_at
|
|
48
|
+
# @return [Integer, nil] the suspect score, or nil when unscored
|
|
49
|
+
attr_reader :suspect_score
|
|
50
|
+
# @return [Array<EventTrigger>] the signal groups behind the score; never
|
|
51
|
+
# nil, and empty when nothing fired
|
|
52
|
+
attr_reader :triggers
|
|
53
|
+
# @return [Hash, nil] the archived submission, when one was kept. Its
|
|
54
|
+
# contents are deliberately opaque, not a public contract.
|
|
55
|
+
attr_reader :document
|
|
56
|
+
# @return [Hash, nil] the deferred report attached to this request, when
|
|
57
|
+
# one arrived. Also opaque.
|
|
58
|
+
attr_reader :deferred_signals
|
|
59
|
+
# @return [Hash] the response body exactly as it arrived, so a field the
|
|
60
|
+
# API adds after this SDK was released needs no upgrade
|
|
61
|
+
attr_reader :raw
|
|
62
|
+
|
|
63
|
+
# @api private
|
|
64
|
+
def initialize(payload)
|
|
65
|
+
STRING_FIELDS.each { |name| instance_variable_set(:"@#{name}", payload[name]) }
|
|
66
|
+
@occurred_at = Models.parse_instant(payload["occurred_at"])
|
|
67
|
+
@suspect_score = payload["suspect_score"]
|
|
68
|
+
@has_document = payload["has_document"] ? true : false
|
|
69
|
+
@triggers = Array(payload["triggers"]).map { |item| EventTrigger.new(item) }.freeze
|
|
70
|
+
@document = payload["document"]
|
|
71
|
+
@deferred_signals = payload["deferred_signals"]
|
|
72
|
+
@raw = payload
|
|
73
|
+
freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Whether the full submission was archived.
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def has_document?
|
|
79
|
+
@has_document
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Whether the API assigned this event a suspect score.
|
|
83
|
+
#
|
|
84
|
+
# Prefer it to a nil check: it states the question being asked.
|
|
85
|
+
# @return [Boolean]
|
|
86
|
+
def scored?
|
|
87
|
+
!@suspect_score.nil?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def inspect
|
|
91
|
+
"#<Fingerly::Event request_id=#{request_id.inspect} visitor_id=#{visitor_id.inspect}>"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# One page of events, newest first.
|
|
96
|
+
class EventPage
|
|
97
|
+
include Enumerable
|
|
98
|
+
|
|
99
|
+
# @return [Array<Event>] the events on this page; never nil, possibly empty
|
|
100
|
+
attr_reader :rows
|
|
101
|
+
# @return [Integer] the one-based page number returned
|
|
102
|
+
attr_reader :page
|
|
103
|
+
# @return [Integer] rows per page the server applied
|
|
104
|
+
attr_reader :page_size
|
|
105
|
+
# @return [Hash] the response body exactly as it arrived
|
|
106
|
+
attr_reader :raw
|
|
107
|
+
|
|
108
|
+
# @api private
|
|
109
|
+
def initialize(payload)
|
|
110
|
+
@rows = Array(payload["rows"]).map { |item| Event.new(item) }.freeze
|
|
111
|
+
@page = payload["page"] || 0
|
|
112
|
+
@page_size = payload["page_size"] || 0
|
|
113
|
+
@raw = payload
|
|
114
|
+
freeze
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Yields each event, so a page is usable anywhere an Enumerable is.
|
|
118
|
+
def each(&block)
|
|
119
|
+
rows.each(&block)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def inspect
|
|
123
|
+
"#<Fingerly::EventPage page=#{page} page_size=#{page_size} rows=#{rows.size}>"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The envelope every webhook delivery shares.
|
|
128
|
+
#
|
|
129
|
+
# {#data} is left as a plain Hash, so a new event type needs no SDK upgrade.
|
|
130
|
+
class WebhookEvent
|
|
131
|
+
# @return [String, nil] identifies this delivery. Persist it to
|
|
132
|
+
# deduplicate: a webhook may arrive more than once.
|
|
133
|
+
attr_reader :id
|
|
134
|
+
# @return [String, nil] the event name, such as +identification.completed+
|
|
135
|
+
attr_reader :type
|
|
136
|
+
# @return [Integer, nil] the envelope version
|
|
137
|
+
attr_reader :version
|
|
138
|
+
# @return [String, nil] the organization the event belongs to
|
|
139
|
+
attr_reader :organization_id
|
|
140
|
+
# @return [String, nil] the deployment the event came from
|
|
141
|
+
attr_reader :environment
|
|
142
|
+
# @return [Time, nil] when Fingerly recorded the event
|
|
143
|
+
attr_reader :created_at
|
|
144
|
+
# @return [Hash] the event-specific payload
|
|
145
|
+
attr_reader :data
|
|
146
|
+
# @return [Hash] the delivery body exactly as it arrived
|
|
147
|
+
attr_reader :raw
|
|
148
|
+
|
|
149
|
+
# @api private
|
|
150
|
+
def initialize(payload)
|
|
151
|
+
@id = payload["id"]
|
|
152
|
+
@type = payload["type"]
|
|
153
|
+
@version = payload["version"]
|
|
154
|
+
@organization_id = payload["organization_id"]
|
|
155
|
+
@environment = payload["environment"]
|
|
156
|
+
@created_at = payload["created_at"] ? Models.parse_instant(payload["created_at"]) : nil
|
|
157
|
+
@data = payload["data"] || {}
|
|
158
|
+
@raw = payload
|
|
159
|
+
freeze
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def inspect
|
|
163
|
+
"#<Fingerly::WebhookEvent id=#{id.inspect} type=#{type.inspect}>"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Shared decoding helpers.
|
|
168
|
+
#
|
|
169
|
+
# @api private
|
|
170
|
+
module Models
|
|
171
|
+
module_function
|
|
172
|
+
|
|
173
|
+
# Reads an RFC 3339 timestamp into a Time.
|
|
174
|
+
#
|
|
175
|
+
# @raise [Error] if the API sent something that is not one
|
|
176
|
+
def parse_instant(value)
|
|
177
|
+
Time.iso8601(value.to_s)
|
|
178
|
+
rescue ArgumentError, TypeError
|
|
179
|
+
raise Error, "Fingerly API returned a timestamp that is not RFC 3339"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|