dregs 0.1.0 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4f2ed05bc7ad6b7f11275cd1433eb3289d2eb9e0591067bbf238bf65c614e1d
4
- data.tar.gz: cdd0d2097662216dee63c3453ea925295f62710adbc546d3c3e8ee1e8ae96b9e
3
+ metadata.gz: bfd935e6538082a551734c1bfeeeb5fe76e8d348e96e3378ace99cf74b65425a
4
+ data.tar.gz: 32d8827024545f2b625f6150ca9601fd588d3b6a36c5b7b240fda3eefcb49c72
5
5
  SHA512:
6
- metadata.gz: 5f41a1a52cdc61c9cdd50f8b8fcba9ebd635070946eeaea1330da0494f167138bc9f0c4ebd6b766181f779bf328a5ad221779cc4d72731a32687e1a1efc05271
7
- data.tar.gz: 86d8d64737dabd2c619b94a45795b774cf5035bfed7dc7494be30f1255ff0c41b2269967e2f52f9941bfdd408baeb33cac6dabccd6b83224ce8ffbb2f84f3b89
6
+ metadata.gz: ebbeda4c7c941594f1d0ca31aa9b06f107fa59ea63b81e9ab4ac350e8fd39d64b5cc4b36881a23a591270fcd85dc46f06e263c01415eb491d74b837a6fe3b540
7
+ data.tar.gz: 13141efc8a036f336fbe7db855aaad71487c370c51110b3e70f89e1313ef3b880b4d4d7c57fe03affd7657577897dddd9a53a935802f04eaca3531835f758c3b
data/CHANGELOG.md CHANGED
@@ -4,7 +4,30 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [Unreleased]
7
+ ## [1.0.0] - Unreleased
8
+
9
+ The first stable release. It catches the SDK up with the public API: groups, the rest of the identity
10
+ reads and writes, events, devices, and escalations, with paging throughout. Nothing from 0.1.0 changes,
11
+ so upgrading needs no code changes.
12
+
13
+ ### Added
14
+
15
+ - `groups:` on `track`: the organizations, teams, workspaces, or other groups the user is acting in,
16
+ each with an `id`, an optional `type` (`"organization"` by default), and optional `data`. Dregs makes
17
+ the identity a member of each.
18
+ - `client.groups`: `list`, `list_all`, `get`, `create`, `update`, `add_member`, and `remove_member`.
19
+ - `identities.list`, `list_all`, `update` (to disregard an identity or merge attributes), `history`,
20
+ `links`, `reviews`, and `review`, and a `cycle_id:` on `analysis` for a past cycle.
21
+ - `client.events`: `list`, `list_all`, and `get`, with the groups each event was sent with.
22
+ - `client.devices`: `list`, `list_all`, `get`, and `update` to disregard a device.
23
+ - `client.escalations`: `list`, `list_all`, `get`, `update` to move an escalation along, and `summary`.
24
+ - `Dregs::Page`, an `Enumerable` page of items with its position and totals, and a `list_all` on every
25
+ namespace that walks the pages for you.
26
+
27
+ ### Fixed
28
+
29
+ - An error response without a message of its own now reads as the status's reason phrase, such as
30
+ "HTTP 404: Not Found", rather than "Request failed".
8
31
 
9
32
  ## [0.1.0] - 2026-09-22
10
33
 
@@ -23,5 +46,5 @@ The first release. A server-side client for tracking events and reading scores.
23
46
  - `Dregs::Webhooks.verify` for checking a webhook's signature and rejecting replays.
24
47
  - Lenient models with a `raw` escape hatch, and RBS signatures in `sig/`.
25
48
 
26
- [Unreleased]: https://github.com/dregs-sdk/dregs-sdk-ruby/compare/v0.1.0...HEAD
49
+ [1.0.0]: https://github.com/dregs-sdk/dregs-sdk-ruby/compare/v0.1.0...v1.0.0
27
50
  [0.1.0]: https://github.com/dregs-sdk/dregs-sdk-ruby/releases/tag/v0.1.0
data/README.md CHANGED
@@ -51,6 +51,26 @@ signature, so the identity is the only thing tying the event to a user.
51
51
  heavily, so send them whenever you have them. Name the keys the way your application already does and
52
52
  map them to Dregs's canonical fields under **Settings → Mappings**; the same goes for event names.
53
53
 
54
+ ### Groups
55
+
56
+ If your application groups users into organizations, teams, workspaces, or the like, pass the groups the
57
+ user is acting in. Dregs records each group and makes the identity a member of it. Each group has your
58
+ own `id`, a `type` that is your own name for the kind of group (`"organization"` when omitted), and
59
+ optional `data` that Dregs merges into the group, so later events can send the type and id alone. Dregs
60
+ normalizes types to lower_snake_case, so `ParentCompany` and `parent-company` are the same type, and an
61
+ event can carry one group of each type.
62
+
63
+ ```ruby
64
+ client.track(
65
+ "user.login",
66
+ identity: "user_12345",
67
+ groups: [
68
+ { type: "organization", id: "org_678", data: { name: "Acme Inc", plan: "enterprise" } },
69
+ { type: "team", id: "team_42", data: { name: "Payments" } }
70
+ ]
71
+ )
72
+ ```
73
+
54
74
  ### Idempotency
55
75
 
56
76
  Every event is sent with an `id`, which makes ingestion idempotent: reposting the same id returns the
@@ -133,6 +153,104 @@ client.identities.analyze("user_12345")
133
153
  This queues the work and returns; it does not wait for the cycle to finish. Dregs rescores on its own
134
154
  as events arrive, so you rarely need this outside of a support or backfill flow.
135
155
 
156
+ ### History, links, and past cycles
157
+
158
+ ```ruby
159
+ history = client.identities.history("user_12345", limit: 10)
160
+
161
+ history.each do |entry|
162
+ puts "#{entry.finished_at} #{entry.humanity} #{entry.behavior}"
163
+ end
164
+
165
+ older = client.identities.analysis("user_12345", cycle_id: history.last.cycle_id) # a past cycle
166
+
167
+ client.identities.links("user_12345").each do |link|
168
+ puts "#{link.identity_id} #{link.type} #{link.explanation}" # e.g. "user_999 SAME_DEVICE ..."
169
+ end
170
+ ```
171
+
172
+ ### Disregarding an identity
173
+
174
+ Leave your own staff and test accounts out of scoring and escalations:
175
+
176
+ ```ruby
177
+ client.identities.update("qa_bot_7", disregarded: true)
178
+ ```
179
+
180
+ ### Asking for a review
181
+
182
+ When a score looks wrong, Dregs's AI reviewer will look over the analysis and say why. The review runs
183
+ in the background, so read it back later with `reviews`.
184
+
185
+ ```ruby
186
+ review = client.identities.review("user_12345", feedback: "This is a long-standing customer.")
187
+
188
+ review.status # "PENDING", then "FINISHED"
189
+ ```
190
+
191
+ ## Listing and paging
192
+
193
+ Every namespace with a `list` returns one `Dregs::Page` at a time, numbered from 0, and a `list_all`
194
+ that walks every page for you:
195
+
196
+ ```ruby
197
+ page = client.identities.list(term: "example.com", page_size: 50)
198
+
199
+ page.total_elements # how many match in all
200
+ page.has_next? # whether another page follows
201
+
202
+ client.identities.list_all(group: "org_678", sort: "humanityScore", order: :asc).each do |identity|
203
+ # ...
204
+ end
205
+ ```
206
+
207
+ A page is `Enumerable` over its items. `list_all` returns an `Enumerator` that fetches each page only
208
+ when the items before it have been used up, so `list_all.first(10)` costs a single request; pass it a
209
+ block instead and it yields every item. Filters you leave out are not sent. A page holds at most 100
210
+ items.
211
+
212
+ ## Groups
213
+
214
+ `track` records groups and memberships as events arrive. The `groups` namespace reads them back, and
215
+ keeps Dregs in step when something changes outside an event, such as a user leaving a team:
216
+
217
+ ```ruby
218
+ client.groups.create("org_678", attributes: { name: "Acme Inc", plan: "enterprise" })
219
+
220
+ group = client.groups.get("organization", "org_678")
221
+ group.identity_count # 12
222
+
223
+ client.groups.update("organization", "org_678", { plan: "business" })
224
+
225
+ client.groups.add_member("team", "team_42", "user_12345")
226
+ client.groups.remove_member("team", "team_42", "user_12345")
227
+
228
+ client.groups.list_all(group_type: "team", identity: "user_12345").each do |team|
229
+ puts "#{team.id} #{team.display_name}"
230
+ end
231
+ ```
232
+
233
+ `create` merges into the group when one with the same type and id already exists, so it is safe to call
234
+ on every sync. The type defaults to `"organization"`.
235
+
236
+ ## Events, devices, and escalations
237
+
238
+ ```ruby
239
+ client.events.list_all(identity: "user_12345").each do |event|
240
+ puts "#{event.timestamp} #{event.name} #{event.canonical_type}"
241
+ end
242
+
243
+ client.devices.update("a1b2c3d4e5", disregarded: true) # e.g. a shared office machine
244
+
245
+ client.escalations.list_all(status: :open, severity: :critical).each do |escalation|
246
+ notify_on_call(escalation.identity.id)
247
+ client.escalations.update(escalation.id, status: :acknowledged)
248
+ end
249
+
250
+ summary = client.escalations.summary
251
+ summary.open # 3
252
+ ```
253
+
136
254
  ## Errors
137
255
 
138
256
  ```ruby
@@ -153,7 +271,7 @@ end
153
271
  | `Dregs::AuthenticationError` | 401, the secret key was not recognized |
154
272
  | `Dregs::QuotaExceededError` | 402, the account is over its monthly event limit |
155
273
  | `Dregs::PermissionDeniedError` | 403, the credential may not do this |
156
- | `Dregs::NotFoundError` | 404, no such identity, or it has not been analyzed |
274
+ | `Dregs::NotFoundError` | 404, no such identity (or group, event, device, escalation), or it has not been analyzed |
157
275
  | `Dregs::RateLimitError` | 429, too many requests |
158
276
  | `Dregs::ServerError` | 5xx |
159
277
  | `Dregs::TimeoutError` | the request timed out |
data/lib/dregs/client.rb CHANGED
@@ -7,7 +7,11 @@ require "securerandom"
7
7
  require "timeout"
8
8
  require "uri"
9
9
 
10
+ require_relative "devices"
10
11
  require_relative "errors"
12
+ require_relative "escalations"
13
+ require_relative "events"
14
+ require_relative "groups"
11
15
  require_relative "identities"
12
16
  require_relative "models"
13
17
  require_relative "version"
@@ -54,6 +58,9 @@ module Dregs
54
58
  STATUS_RATE_LIMITED = "rate_limited"
55
59
  STATUS_QUOTA_EXCEEDED = "quota_exceeded"
56
60
 
61
+ # The type a group is sent with when the caller does not name one.
62
+ DEFAULT_GROUP_TYPE = "organization"
63
+
57
64
  # @return [String] the API root every request is built against, without a trailing slash
58
65
  attr_reader :base_url
59
66
 
@@ -66,6 +73,18 @@ module Dregs
66
73
  # @return [Identities] read identities, their scores, and their analysis
67
74
  attr_reader :identities
68
75
 
76
+ # @return [Groups] read and manage groups and their members
77
+ attr_reader :groups
78
+
79
+ # @return [Events] read the events Dregs has recorded
80
+ attr_reader :events
81
+
82
+ # @return [Devices] read devices, and disregard them
83
+ attr_reader :devices
84
+
85
+ # @return [Escalations] read and work escalations
86
+ attr_reader :escalations
87
+
69
88
  # @param secret_key [String, nil] your credential's secret key; defaults to +$DREGS_SECRET_KEY+
70
89
  # @param base_url [String, nil] the API root; defaults to +$DREGS_BASE_URL+, then
71
90
  # {DEFAULT_BASE_URL}
@@ -88,6 +107,10 @@ module Dregs
88
107
  @timeout = timeout
89
108
  @max_retries = max_retries
90
109
  @identities = Identities.new(self)
110
+ @groups = Groups.new(self)
111
+ @events = Events.new(self)
112
+ @devices = Devices.new(self)
113
+ @escalations = Escalations.new(self)
91
114
  end
92
115
 
93
116
  # Records a backend event against an identity.
@@ -96,7 +119,8 @@ module Dregs
96
119
  # "user.signup",
97
120
  # identity: "user_12345",
98
121
  # data: { plan: "pro", referrer: "partner-x" },
99
- # identity_data: { email: "ada@example.com", name: "Ada Lovelace" }
122
+ # identity_data: { email: "ada@example.com", name: "Ada Lovelace" },
123
+ # groups: [{ type: "team", id: "team_42", data: { name: "Payments" } }]
100
124
  # )
101
125
  #
102
126
  # @param event_type [String] your name for the event, such as +"user.signup"+. Map it to one
@@ -111,6 +135,11 @@ module Dregs
111
135
  # Dregs merges these into the identity, and the analyzers lean on them heavily, so send them
112
136
  # whenever you have them. Flat keys work best; name them as your application already does
113
137
  # and map them under *Settings -> Mappings*.
138
+ # @param groups [Array<Hash>, nil] the groups the user is acting in, such as their organization
139
+ # or team, if your application has them. Each is a hash with an +id+ (your own), an optional
140
+ # +type+ (your own name for the kind of group, defaulting to +"organization"+), and optional
141
+ # +data+ that Dregs merges into the group. Dregs makes the identity a member of each. At most
142
+ # one group of each type. Keys may be symbols or strings.
114
143
  # @param event_id [String, nil] your own id for the event, which makes ingestion idempotent:
115
144
  # reposting the same id returns the original event instead of recording a second one. Pass
116
145
  # the id your application already has (the row id of the record that triggered the event,
@@ -124,13 +153,15 @@ module Dregs
124
153
  # @raise [RateLimitError] the credential is ingesting too fast
125
154
  # @raise [AuthenticationError] the secret key was not recognized
126
155
  # @raise [BadRequestError] the event was malformed
127
- # @raise [ArgumentError] the event type, identity, or event id was unusable
128
- def track(event_type, identity:, data: {}, identity_data: {}, event_id: nil, timestamp: nil, source: nil)
156
+ # @raise [ArgumentError] the event type, identity, event id, or groups were unusable
157
+ def track(event_type, identity:, data: {}, identity_data: {}, groups: nil, event_id: nil, timestamp: nil,
158
+ source: nil)
129
159
  body = track_body(
130
160
  event_type,
131
161
  identity: identity,
132
162
  data: data,
133
163
  identity_data: identity_data,
164
+ groups: groups,
134
165
  event_id: event_id,
135
166
  timestamp: timestamp,
136
167
  source: source
@@ -142,12 +173,17 @@ module Dregs
142
173
  # Performs a request, retrying the failures worth retrying.
143
174
  #
144
175
  # @api private
145
- # @param method [Symbol] +:get+ or +:post+
146
- # @param path [String] the path below the base URL, with a leading slash
147
- # @param body [Hash, nil] a JSON body, for POSTs that carry one
176
+ # @param method [Symbol] +:get+, +:post+, +:put+, +:patch+, or +:delete+
177
+ # @param path [String] the path below the base URL, with a leading slash, its segments already
178
+ # escaped
179
+ # @param body [Hash, nil] a JSON body, for the requests that carry one
180
+ # @param params [Hash, nil] query parameters. Those that are nil are left out, and booleans are
181
+ # spelled +true+ and +false+ as the API's binder reads them.
148
182
  # @return [Hash, Array, nil] the parsed response body
149
- def request(method, path, body: nil)
183
+ def request(method, path, body: nil, params: nil)
150
184
  uri = URI.parse(url_for(path))
185
+ query = query_string(params)
186
+ uri.query = query unless query.nil?
151
187
  attempt = 0
152
188
 
153
189
  loop do
@@ -208,7 +244,7 @@ module Dregs
208
244
  # An event id is always sent. When the caller has an id of their own it is used verbatim, so
209
245
  # reposting the same event is a no-op on the Dregs side; otherwise one is generated, which is
210
246
  # what makes this client's own retries safe to perform.
211
- def track_body(event_type, identity:, data:, identity_data:, event_id:, timestamp:, source:)
247
+ def track_body(event_type, identity:, data:, identity_data:, groups:, event_id:, timestamp:, source:)
212
248
  raise ArgumentError, "event_type is required." if event_type.nil? || event_type.to_s.empty?
213
249
 
214
250
  if identity.nil? || identity.to_s.empty?
@@ -217,6 +253,7 @@ module Dregs
217
253
  "identity is the only thing tying the event to a user."
218
254
  end
219
255
 
256
+ resolved_groups = groups_body(groups)
220
257
  resolved_id = event_id || SecureRandom.uuid.delete("-")
221
258
 
222
259
  if resolved_id.start_with?("dregs-")
@@ -233,11 +270,63 @@ module Dregs
233
270
  "source" => source || "ruby-sdk"
234
271
  }
235
272
 
273
+ body["groups"] = resolved_groups unless resolved_groups.empty?
236
274
  body["timestamp"] = iso8601(timestamp) unless timestamp.nil?
237
275
 
238
276
  body
239
277
  end
240
278
 
279
+ # Builds the +groups+ array of a track body, refusing malformed entries before any request.
280
+ #
281
+ # Each group needs an +id+. Its +type+ defaults to +"organization"+, and Dregs normalizes it to
282
+ # lower_snake_case, so only one group of each type may go with an event.
283
+ def groups_body(groups)
284
+ return [] if groups.nil?
285
+
286
+ unless groups.is_a?(Array)
287
+ raise ArgumentError, "groups is an array of groups, such as [{ type: \"team\", id: \"team_42\" }]."
288
+ end
289
+
290
+ seen = []
291
+
292
+ groups.map do |group|
293
+ unless group.is_a?(Hash)
294
+ raise ArgumentError, "Each group must be a hash with an id, and optionally a type and data."
295
+ end
296
+
297
+ group_id = group_value(group, :id)
298
+
299
+ raise ArgumentError, "Each group needs an id." if group_id.nil? || group_id.to_s.empty?
300
+
301
+ group_type = group_value(group, :type)
302
+ group_type = DEFAULT_GROUP_TYPE if group_type.nil? || group_type == ""
303
+
304
+ unless group_type.is_a?(String)
305
+ raise ArgumentError, "A group's type must be a string, such as \"organization\" or \"team\"."
306
+ end
307
+
308
+ if seen.include?(group_type.downcase)
309
+ raise ArgumentError,
310
+ "Only one group of each type can go with an event; \"#{group_type}\" appears twice."
311
+ end
312
+
313
+ data = group_value(group, :data)
314
+
315
+ unless data.nil? || data.is_a?(Hash)
316
+ raise ArgumentError, "A group's data must be a hash, such as { name: \"Payments\" }."
317
+ end
318
+
319
+ seen << group_type.downcase
320
+
321
+ { "type" => group_type, "id" => group_id.to_s, "data" => data || {} }
322
+ end
323
+ end
324
+
325
+ # Reads a group's key whether the caller spelled it as a symbol or a string.
326
+ def group_value(group, key)
327
+ group.key?(key) ? group[key] : group[key.to_s]
328
+ end
329
+
241
330
  # Formats a timestamp the way the API's +Instant+ parser expects.
242
331
  #
243
332
  # A +Time+ is converted to UTC rather than sent with its offset, and a string is trusted as
@@ -254,6 +343,15 @@ module Dregs
254
343
  "#{base_url}/#{path.sub(%r{\A/+}, "")}"
255
344
  end
256
345
 
346
+ # Drops unset parameters and encodes the rest, or returns nil when nothing is left to send.
347
+ def query_string(params)
348
+ return nil if params.nil?
349
+
350
+ pairs = params.compact.map { |name, value| [name.to_s, value.to_s] }
351
+
352
+ pairs.empty? ? nil : URI.encode_www_form(pairs)
353
+ end
354
+
257
355
  def headers
258
356
  {
259
357
  "Authorization" => "Bearer #{@secret_key}",
@@ -285,6 +383,9 @@ module Dregs
285
383
  case method
286
384
  when :get then Net::HTTP::Get.new(uri)
287
385
  when :post then Net::HTTP::Post.new(uri)
386
+ when :put then Net::HTTP::Put.new(uri)
387
+ when :patch then Net::HTTP::Patch.new(uri)
388
+ when :delete then Net::HTTP::Delete.new(uri)
288
389
  else raise ArgumentError, "Unsupported HTTP method: #{method}."
289
390
  end
290
391
  end
@@ -339,8 +440,8 @@ module Dregs
339
440
  end
340
441
 
341
442
  def api_error(response, payload, status)
342
- message = message_from(payload) || response.message.to_s
343
- message = "Request failed" if message.empty?
443
+ message = message_from(payload) || response.message.to_s.strip
444
+ message = Errors.reason_phrase(status) if message.empty?
344
445
 
345
446
  return rate_limit_error(response, payload, message) if status == 429
346
447
 
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models"
4
+ require_relative "resource"
5
+
6
+ module Dregs
7
+ # The +client.devices+ namespace: the browsers and devices Dregs has seen, identified by their
8
+ # fingerprints.
9
+ class Devices < Resource
10
+ # Returns one page of devices, most recently seen first. Use {#list_all} to walk every page.
11
+ #
12
+ # @param identity [String, nil] only devices this identity was seen on
13
+ # @param term [String, nil] free text matched against fingerprints, addresses, places, and user
14
+ # agents
15
+ # @param page [Integer] the page to return, counting from 0
16
+ # @param page_size [Integer, nil] items per page, at most 100
17
+ # @param sort [String, nil] the field to sort on, such as +"lastSeenAt"+ or +"country"+
18
+ # @param order [Symbol, String, nil] +:asc+ or +:desc+
19
+ # @return [Page<Device>]
20
+ # @raise [ArgumentError] the page is negative or the order is not one of the two
21
+ def list(identity: nil, term: nil, page: 0, page_size: nil, sort: nil, order: nil)
22
+ filters = { "identity" => identity, "term" => term }
23
+
24
+ list_page("/devices", Device, filters, page: page, page_size: page_size, sort: sort, order: order)
25
+ end
26
+
27
+ # Walks every device that matches, fetching pages as it goes. Takes the filters {#list} does.
28
+ #
29
+ # @return [Enumerator<Device>] a lazy enumerator, which fetches a page only when the items
30
+ # before it have been used up; nil when a block is given, which is called with each device
31
+ # @raise [ArgumentError] the order is not one of the two
32
+ def list_all(identity: nil, term: nil, page_size: nil, sort: nil, order: nil, &block)
33
+ filters = { "identity" => identity, "term" => term }
34
+
35
+ list_every("/devices", Device, filters, page_size: page_size, sort: sort, order: order, &block)
36
+ end
37
+
38
+ # Returns the device with this fingerprint.
39
+ #
40
+ # @param fingerprint [String] the device's fingerprint
41
+ # @return [Device]
42
+ # @raise [NotFoundError] no device has this fingerprint
43
+ def get(fingerprint)
44
+ Device.from_api(@client.request(:get, path_for(fingerprint)))
45
+ end
46
+
47
+ # Sets whether the device is disregarded, and returns it.
48
+ #
49
+ # A disregarded device, such as a shared office machine, no longer links the identities seen
50
+ # on it.
51
+ #
52
+ # client.devices.update("a1b2c3d4e5", disregarded: true)
53
+ #
54
+ # @param fingerprint [String] the device's fingerprint
55
+ # @param disregarded [Boolean] whether to disregard it
56
+ # @return [Device]
57
+ # @raise [NotFoundError] no device has this fingerprint
58
+ def update(fingerprint, disregarded:)
59
+ body = { "disregarded" => disregarded ? true : false }
60
+
61
+ Device.from_api(@client.request(:patch, path_for(fingerprint), body: body))
62
+ end
63
+
64
+ private
65
+
66
+ def path_for(fingerprint)
67
+ "/devices/#{segment(fingerprint, "A device fingerprint")}"
68
+ end
69
+ end
70
+ end
data/lib/dregs/errors.rb CHANGED
@@ -115,7 +115,7 @@ module Dregs
115
115
  # 5xx. Something went wrong inside Dregs. These are retried automatically.
116
116
  class ServerError < APIError; end
117
117
 
118
- # Maps HTTP statuses to the error class that represents them.
118
+ # Maps HTTP statuses to the error class that represents them, and to their reason phrases.
119
119
  module Errors
120
120
  STATUS_ERRORS = {
121
121
  400 => BadRequestError,
@@ -126,6 +126,48 @@ module Dregs
126
126
  429 => RateLimitError
127
127
  }.freeze
128
128
 
129
+ # The standard reason phrase for each status an error response is likely to carry.
130
+ #
131
+ # An error body without a message of its own still deserves one more useful than "Request
132
+ # failed", and a server or proxy speaking HTTP/2 sends no reason phrase on the status line to
133
+ # fall back to.
134
+ REASON_PHRASES = {
135
+ 400 => "Bad Request",
136
+ 401 => "Unauthorized",
137
+ 402 => "Payment Required",
138
+ 403 => "Forbidden",
139
+ 404 => "Not Found",
140
+ 405 => "Method Not Allowed",
141
+ 406 => "Not Acceptable",
142
+ 408 => "Request Timeout",
143
+ 409 => "Conflict",
144
+ 410 => "Gone",
145
+ 411 => "Length Required",
146
+ 412 => "Precondition Failed",
147
+ 413 => "Content Too Large",
148
+ 414 => "URI Too Long",
149
+ 415 => "Unsupported Media Type",
150
+ 418 => "I'm a teapot",
151
+ 422 => "Unprocessable Content",
152
+ 425 => "Too Early",
153
+ 428 => "Precondition Required",
154
+ 429 => "Too Many Requests",
155
+ 431 => "Request Header Fields Too Large",
156
+ 500 => "Internal Server Error",
157
+ 501 => "Not Implemented",
158
+ 502 => "Bad Gateway",
159
+ 503 => "Service Unavailable",
160
+ 504 => "Gateway Timeout"
161
+ }.freeze
162
+
163
+ # Returns the standard reason phrase for +status_code+, such as +"Not Found"+ for 404.
164
+ #
165
+ # @param status_code [Integer]
166
+ # @return [String] the phrase, or +"Request failed"+ for a status without a standard one
167
+ def self.reason_phrase(status_code)
168
+ REASON_PHRASES.fetch(status_code, "Request failed")
169
+ end
170
+
129
171
  # Returns the exception class that represents +status_code+.
130
172
  #
131
173
  # Anything at or above 500 is a {ServerError}; an unmapped 4xx falls back to the {APIError}
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models"
4
+ require_relative "resource"
5
+
6
+ module Dregs
7
+ # The +client.escalations+ namespace: the escalations your escalation rules open, and moving
8
+ # them along.
9
+ class Escalations < Resource
10
+ # The statuses an escalation moves through.
11
+ STATUSES = %w[OPEN ACKNOWLEDGED CLOSED].freeze
12
+
13
+ # Returns one page of escalations, newest first. Use {#list_all} to walk every page.
14
+ #
15
+ # @param status [Symbol, String, nil] only escalations in this status: +:open+,
16
+ # +:acknowledged+, or +:closed+
17
+ # @param severity [Symbol, String, nil] only escalations of this severity: +:info+, +:warning+,
18
+ # or +:critical+
19
+ # @param identity [String, nil] only escalations about this identity
20
+ # @param rule [Integer, String, nil] only escalations opened by the escalation rule with this id
21
+ # @param page [Integer] the page to return, counting from 0
22
+ # @param page_size [Integer, nil] items per page, at most 100
23
+ # @param sort [String, nil] the field to sort on, such as +"openedAt"+ or +"severity"+
24
+ # @param order [Symbol, String, nil] +:asc+ or +:desc+
25
+ # @return [Page<Escalation>]
26
+ # @raise [ArgumentError] the status is not one of the three, the page is negative, or the order
27
+ # is not one of the two
28
+ def list(status: nil, severity: nil, identity: nil, rule: nil, page: 0, page_size: nil, sort: nil,
29
+ order: nil)
30
+ filters = list_filters(status: status, severity: severity, identity: identity, rule: rule)
31
+
32
+ list_page(
33
+ "/escalations", Escalation, filters, page: page, page_size: page_size, sort: sort, order: order
34
+ )
35
+ end
36
+
37
+ # Walks every escalation that matches, fetching pages as it goes. Takes the filters {#list}
38
+ # does.
39
+ #
40
+ # client.escalations.list_all(status: :open, severity: :critical).each do |escalation|
41
+ # notify_on_call(escalation.identity.id)
42
+ # client.escalations.update(escalation.id, status: :acknowledged)
43
+ # end
44
+ #
45
+ # @return [Enumerator<Escalation>] a lazy enumerator, which fetches a page only when the items
46
+ # before it have been used up; nil when a block is given, which is called with each escalation
47
+ # @raise [ArgumentError] the status is not one of the three, or the order is not one of the two
48
+ def list_all(status: nil, severity: nil, identity: nil, rule: nil, page_size: nil, sort: nil, order: nil,
49
+ &block)
50
+ filters = list_filters(status: status, severity: severity, identity: identity, rule: rule)
51
+
52
+ list_every("/escalations", Escalation, filters, page_size: page_size, sort: sort, order: order, &block)
53
+ end
54
+
55
+ # Returns the escalation with this id.
56
+ #
57
+ # @param escalation_id [Integer, String]
58
+ # @return [Escalation]
59
+ # @raise [NotFoundError] no escalation has this id
60
+ def get(escalation_id)
61
+ Escalation.from_api(@client.request(:get, path_for(escalation_id)))
62
+ end
63
+
64
+ # Moves the escalation to +status+ and returns it.
65
+ #
66
+ # @param escalation_id [Integer, String]
67
+ # @param status [Symbol, String] +:open+, +:acknowledged+, or +:closed+
68
+ # @return [Escalation]
69
+ # @raise [NotFoundError] no escalation has this id
70
+ # @raise [ArgumentError] the status is not one of the three
71
+ def update(escalation_id, status:)
72
+ body = { "status" => status_param(status) }
73
+
74
+ Escalation.from_api(@client.request(:patch, path_for(escalation_id), body: body))
75
+ end
76
+
77
+ # Returns how many escalations are open, acknowledged, and closed.
78
+ #
79
+ # @return [EscalationSummary]
80
+ def summary
81
+ EscalationSummary.from_api(@client.request(:get, "/escalations/summary"))
82
+ end
83
+
84
+ private
85
+
86
+ def path_for(escalation_id)
87
+ "/escalations/#{segment(escalation_id, "An escalation id")}"
88
+ end
89
+
90
+ def list_filters(status:, severity:, identity:, rule:)
91
+ {
92
+ "status" => status.nil? ? nil : status_param(status),
93
+ "severity" => severity.nil? || severity.to_s.empty? ? nil : severity.to_s.upcase,
94
+ "identity" => identity,
95
+ "rule" => rule
96
+ }
97
+ end
98
+
99
+ def status_param(status)
100
+ value = status.to_s.upcase if status.is_a?(String) || status.is_a?(Symbol)
101
+
102
+ raise ArgumentError, "status is :open, :acknowledged, or :closed." unless STATUSES.include?(value)
103
+
104
+ value
105
+ end
106
+ end
107
+ end