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.
@@ -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