flightbyte 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.
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlightByte
4
+ # A read-only view of a JSON object the API sent.
5
+ #
6
+ # Fields read as methods with snake_case names (`flight.departure.delay_minutes`),
7
+ # or with `[]` and the API's own camelCase key (`flight["departure"]["delayMinutes"]`).
8
+ # {#to_h} returns the JSON exactly as it was received.
9
+ #
10
+ # Each response has its own subclass, such as {FlightSnapshot} or
11
+ # {Subscription}, that declares the fields this version knows. A known field
12
+ # the API left out reads as `nil`, so a delay of `0` and no delay reading stay
13
+ # apart. A field the API added after this release still reads as a method
14
+ # and with `[]`; a name that is neither raises `NoMethodError`, so a typo does
15
+ # not pass for a missing value.
16
+ #
17
+ # Objects are frozen and safe to share between threads. Timestamps stay ISO
18
+ # 8601 strings and enums stay strings, exactly as the API sent them.
19
+ class Object
20
+ @fields = {}.freeze
21
+
22
+ class << self
23
+ # The fields this class declares: the API's key to the type its value is
24
+ # wrapped in (`nil` for a plain JSON value).
25
+ #
26
+ # @return [Hash{String => Object}]
27
+ attr_reader :fields
28
+
29
+ # The snake_case method name for an API key: `delayMinutes` reads as
30
+ # `delay_minutes`.
31
+ #
32
+ # @param key [String]
33
+ # @return [String]
34
+ def method_name(key)
35
+ key.to_s
36
+ .gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
37
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
38
+ .tr("-", "_")
39
+ .downcase
40
+ end
41
+
42
+ # @api private
43
+ def inherited(subclass)
44
+ super
45
+ subclass.instance_variable_set(:@fields, fields)
46
+ end
47
+
48
+ # @api private
49
+ # Wraps already frozen JSON without copying it again.
50
+ def build(frozen)
51
+ allocate.tap { |object| object.send(:setup, frozen) }
52
+ end
53
+
54
+ # @api private
55
+ # Wraps a decoded response body, which must be a JSON object.
56
+ def from_response(data)
57
+ unless data.is_a?(Hash)
58
+ raise Error, "flightbyte: expected a JSON object from the API for #{name}, got #{data.inspect[0, 100]}"
59
+ end
60
+
61
+ build(deep_freeze_copy(data))
62
+ end
63
+
64
+ # @api private
65
+ # Wraps the `data` list of a decoded `{ count, data }` response.
66
+ def list_from_response(data)
67
+ list = data.is_a?(Hash) ? data["data"] : nil
68
+ unless list.is_a?(Array)
69
+ raise Error, "flightbyte: expected a JSON object with a data list from the API for #{name}"
70
+ end
71
+
72
+ list.map { |item| from_response(item) }.freeze
73
+ end
74
+
75
+ private
76
+
77
+ # Declares a field: a reader named after `key` in snake_case, and with
78
+ # `predicate: true` a `?` reader that is true only when the API sent `true`.
79
+ #
80
+ # `type` is a subclass to wrap an object in, `[Subclass]` for a list of
81
+ # them, or `:map` for a map of your own strings such as `metadata`, which
82
+ # reads as a frozen `Hash`.
83
+ def field(key, type = nil, predicate: false)
84
+ @fields = fields.merge(key => type).freeze
85
+ name = Object.method_name(key)
86
+ define_method(name) { @values[key] }
87
+ define_method("#{name}?") { @values[key] == true } if predicate
88
+ name.to_sym
89
+ end
90
+ end
91
+
92
+ # Wraps `data`, a Hash shaped like the API's JSON. Keys may be strings or
93
+ # symbols; the data is copied, so changing `data` later changes nothing here.
94
+ #
95
+ # @param data [Hash]
96
+ def initialize(data = {})
97
+ raise TypeError, "flightbyte: #{self.class} needs a Hash, got #{data.class}." unless data.is_a?(Hash)
98
+
99
+ setup(Object.deep_freeze_copy(data))
100
+ end
101
+
102
+ # The value under the API's own key, wrapped as the method would return it.
103
+ #
104
+ # @param key [String, Symbol] the camelCase key, for example `"delayMinutes"`
105
+ # @return [Object, nil]
106
+ def [](key)
107
+ @values[key.to_s]
108
+ end
109
+
110
+ # Reads a nested value, as `Hash#dig` does: `flight.dig("departure", "gate")`.
111
+ #
112
+ # @return [Object, nil]
113
+ def dig(key, *rest)
114
+ value = self[key]
115
+ return value if rest.empty? || value.nil?
116
+
117
+ value.dig(*rest)
118
+ end
119
+
120
+ # Whether the API sent `key`, even as `null`.
121
+ #
122
+ # @param key [String, Symbol]
123
+ def key?(key)
124
+ @raw.key?(key.to_s)
125
+ end
126
+
127
+ # The keys the API sent, in its order.
128
+ #
129
+ # @return [Array<String>]
130
+ def keys
131
+ @raw.keys
132
+ end
133
+
134
+ # The JSON exactly as the API sent it, with string keys. A fresh copy each
135
+ # time, so changing it changes nothing here.
136
+ #
137
+ # @return [Hash{String => Object}]
138
+ def to_h
139
+ Object.deep_copy(@raw)
140
+ end
141
+
142
+ # The JSON as received; used by Rails' `render json:`.
143
+ #
144
+ # @return [Hash{String => Object}]
145
+ def as_json(*)
146
+ to_h
147
+ end
148
+
149
+ # The JSON as received, serialized.
150
+ #
151
+ # @return [String]
152
+ def to_json(*args)
153
+ to_h.to_json(*args)
154
+ end
155
+
156
+ # Pattern matching with snake_case keys:
157
+ #
158
+ # case flight
159
+ # in { status: { phase: "landed" | "arrived" }, arrival: { gate: String => gate } }
160
+ # puts "Landed at gate #{gate}"
161
+ # end
162
+ #
163
+ # @return [Hash{Symbol => Object}]
164
+ def deconstruct_keys(keys)
165
+ names = self.class.fields.keys.to_h { |key| [Object.method_name(key).to_sym, key] }.merge(@extra)
166
+ names = names.slice(*keys) if keys
167
+ names.transform_values { |key| @values[key] }
168
+ end
169
+
170
+ # Equal when the class and the JSON are the same.
171
+ def ==(other)
172
+ other.instance_of?(self.class) && other.raw == @raw
173
+ end
174
+ alias eql? ==
175
+
176
+ # @return [Integer]
177
+ def hash
178
+ [self.class, @raw].hash
179
+ end
180
+
181
+ # Shows the JSON, with any signing secret masked so it stays out of logs.
182
+ #
183
+ # @return [String]
184
+ def inspect
185
+ "#<#{self.class.name} #{Object.mask_secrets(@raw).inspect}>"
186
+ end
187
+ alias to_s inspect
188
+
189
+ # @api private
190
+ def pretty_print(printer)
191
+ printer.text(inspect)
192
+ end
193
+
194
+ # @api private
195
+ def respond_to_missing?(name, include_private = false)
196
+ @extra.key?(name) || super
197
+ end
198
+
199
+ # @api private
200
+ def method_missing(name, *args, &block)
201
+ key = @extra[name]
202
+ return super if key.nil? || !args.empty? || block
203
+
204
+ @values[key]
205
+ end
206
+
207
+ protected
208
+
209
+ # @api private
210
+ attr_reader :raw
211
+
212
+ private
213
+
214
+ def setup(frozen)
215
+ fields = self.class.fields
216
+ @raw = frozen
217
+ @values = {}
218
+ @extra = {}
219
+ frozen.each do |key, value|
220
+ @extra[Object.method_name(key).to_sym] = key unless fields.key?(key)
221
+ @values[key] = Object.wrap(value, fields[key])
222
+ end
223
+ @values.freeze
224
+ @extra.freeze
225
+ freeze
226
+ end
227
+
228
+ class << self
229
+ # @api private
230
+ def wrap(value, type)
231
+ case value
232
+ when Hash
233
+ return value if type == :map
234
+
235
+ (type.is_a?(Class) && type <= Object ? type : Object).build(value)
236
+ when Array
237
+ element = type.is_a?(Array) ? type.first : nil
238
+ value.map { |item| wrap(item, element) }.freeze
239
+ else
240
+ value
241
+ end
242
+ end
243
+
244
+ # @api private
245
+ def deep_freeze_copy(value)
246
+ case value
247
+ when Hash then value.to_h { |key, item| [key.to_s.dup.freeze, deep_freeze_copy(item)] }.freeze
248
+ when Array then value.map { |item| deep_freeze_copy(item) }.freeze
249
+ when String then value.frozen? ? value : value.dup.freeze
250
+ when Object then deep_freeze_copy(value.to_h)
251
+ else value
252
+ end
253
+ end
254
+
255
+ # @api private
256
+ def deep_copy(value)
257
+ case value
258
+ when Hash then value.to_h { |key, item| [key, deep_copy(item)] }
259
+ when Array then value.map { |item| deep_copy(item) }
260
+ when String then value.dup
261
+ else value
262
+ end
263
+ end
264
+
265
+ # @api private
266
+ def mask_secrets(value)
267
+ case value
268
+ when Hash
269
+ value.to_h do |key, item|
270
+ [key, key == "secret" && item.is_a?(String) ? FlightByte.mask_secret(item) : mask_secrets(item)]
271
+ end
272
+ when Array then value.map { |item| mask_secrets(item) }
273
+ else value
274
+ end
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlightByte
4
+ # One quota window, from the `X-RateLimit-*` response headers.
5
+ #
6
+ # @!attribute [r] limit
7
+ # @return [Integer] Requests the window allows.
8
+ # @!attribute [r] remaining
9
+ # @return [Integer] Requests left in the window.
10
+ # @!attribute [r] reset_at
11
+ # @return [Time, nil] When the window resets, in UTC, or `nil` when not reported.
12
+ RateLimitWindow = Struct.new(:limit, :remaining, :reset_at, keyword_init: true)
13
+
14
+ # The quota state sent with a response, from {Client#last_rate_limit}. A
15
+ # window the key has no limit on is `nil`; keys without limits get no
16
+ # headers at all. Frozen, so it can be shared and never changes the client.
17
+ #
18
+ # @!attribute [r] minute
19
+ # @return [RateLimitWindow, nil] The per-minute window.
20
+ # @!attribute [r] day
21
+ # @return [RateLimitWindow, nil] The per-day window.
22
+ # @!attribute [r] month
23
+ # @return [RateLimitWindow, nil] The per-month window.
24
+ RateLimit = Struct.new(:minute, :day, :month, keyword_init: true) do
25
+ # @api private
26
+ # Reads the quota headers (lower-case names), or returns `nil` when there are none.
27
+ def self.parse(headers)
28
+ windows = %w[minute day month].to_h { |name| [name.to_sym, parse_window(headers, name)] }
29
+ return nil if windows.values.none?
30
+
31
+ new(**windows).freeze
32
+ end
33
+
34
+ # @api private
35
+ def self.parse_window(headers, name)
36
+ limit = parse_integer(headers["x-ratelimit-limit-#{name}"])
37
+ remaining = parse_integer(headers["x-ratelimit-remaining-#{name}"])
38
+ return nil if limit.nil? || remaining.nil?
39
+
40
+ reset = parse_integer(headers["x-ratelimit-reset-#{name}"])
41
+ reset_at = reset && Time.at(reset).utc.freeze
42
+ RateLimitWindow.new(limit: limit, remaining: remaining, reset_at: reset_at).freeze
43
+ end
44
+
45
+ # @api private
46
+ def self.parse_integer(value)
47
+ value = value.to_s.strip
48
+ value.match?(/\A\d+\z/) ? Integer(value, 10) : nil
49
+ end
50
+
51
+ private_class_method :parse_window, :parse_integer
52
+ end
53
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlightByte
4
+ # Country names, emoji and flags. Reach it as `client.countries`.
5
+ class Countries
6
+ # @api private
7
+ def initialize(transport)
8
+ @transport = transport
9
+ end
10
+
11
+ # Every country, sorted by name.
12
+ #
13
+ # @param lang [String, nil] the language of the names, for example `tr`; the API's default is `en`
14
+ # @param timeout [Numeric, nil] seconds for this request instead of the client's
15
+ # @return [Array<Country>]
16
+ def list(lang: nil, timeout: nil)
17
+ Country.list_from_response(@transport.request("GET", ["countries"], query: { lang: lang }, timeout: timeout))
18
+ end
19
+
20
+ # One country by its ISO 3166-1 alpha-2 code.
21
+ #
22
+ # @param code [String] for example `TR`
23
+ # @param (see #list)
24
+ # @return [Country]
25
+ # @raise [NotFoundError] when there is no such country
26
+ def get(code, lang: nil, timeout: nil)
27
+ path = ["countries", Transport.required("code", code)]
28
+ Country.from_response(@transport.request("GET", path, query: { lang: lang }, timeout: timeout))
29
+ end
30
+
31
+ # The URL of a country's SVG flag: 4:3, or 1:1 with `square: true`. Flags
32
+ # need no API key, so the URL can go straight into an `<img>`. Nothing is
33
+ # sent.
34
+ #
35
+ # @param code [String] for example `TR`
36
+ # @param square [Boolean]
37
+ # @return [String]
38
+ def flag_url(code, square: false)
39
+ @transport.url(["flags", "#{Transport.required("code", code).downcase}.svg"], { shape: square ? "1x1" : nil })
40
+ end
41
+
42
+ # Never shows the API key.
43
+ def inspect
44
+ "#<#{self.class.name}>"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlightByte
4
+ # Flight lookups. Reach it as `client.flights`.
5
+ class Flights
6
+ # @api private
7
+ def initialize(transport)
8
+ @transport = transport
9
+ end
10
+
11
+ # Looks a flight up. The API upper-cases the carrier and strips leading
12
+ # zeros from the flight number.
13
+ #
14
+ # A flight number can fly several legs on one date. The snapshot lists them
15
+ # all in `alternates`; pass `origin:` (and optionally `destination:`) or a
16
+ # leg's `flight_id:` to choose one.
17
+ #
18
+ # @param carrier [String] IATA or ICAO carrier code, for example `TK`
19
+ # @param flight_number [String, Integer] flight number, for example `1848`
20
+ # @param date [String, Date, Time] local departure date at the origin, `YYYY-MM-DD`
21
+ # @param origin [String, nil] selects a leg by its origin IATA code
22
+ # @param destination [String, nil] narrows the leg further by its destination IATA code
23
+ # @param flight_id [String, nil] selects a leg by its upstream id, from `alternates`
24
+ # @param refresh [Boolean] skips the API's cache and asks upstream
25
+ # @param timeout [Numeric, nil] seconds for this request instead of the client's
26
+ # @return [FlightSnapshot]
27
+ # @raise [NotFoundError] when the flight did not operate that day
28
+ def get(carrier:, flight_number:, date:, origin: nil, destination: nil, flight_id: nil, refresh: false,
29
+ timeout: nil)
30
+ path = flight_path(carrier, flight_number, date)
31
+ query = { origin: origin, destination: destination, flightId: flight_id, refresh: refresh ? "true" : nil }
32
+ FlightSnapshot.from_response(@transport.request("GET", path, query: query, timeout: timeout))
33
+ end
34
+
35
+ # The flight's change history as recorded by the airline, newest first.
36
+ #
37
+ # @param (see #get)
38
+ # @return [FlightTimeline]
39
+ def timeline(carrier:, flight_number:, date:, origin: nil, destination: nil, timeout: nil)
40
+ path = [*flight_path(carrier, flight_number, date), "timeline"]
41
+ query = { origin: origin, destination: destination }
42
+ FlightTimeline.from_response(@transport.request("GET", path, query: query, timeout: timeout))
43
+ end
44
+
45
+ # Never shows the API key.
46
+ def inspect
47
+ "#<#{self.class.name}>"
48
+ end
49
+
50
+ private
51
+
52
+ def flight_path(carrier, flight_number, date)
53
+ [
54
+ "flights",
55
+ Transport.required("carrier", carrier),
56
+ Transport.required("flight_number", flight_number),
57
+ Transport.date("date", date)
58
+ ]
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlightByte
4
+ # Watched flights and their webhooks. Reach it as `client.subscriptions`.
5
+ # Every subscription is a billable watched flight.
6
+ class Subscriptions
7
+ # The page size {#list_all} asks for unless told otherwise: the API's largest.
8
+ DEFAULT_PAGE_SIZE = 200
9
+
10
+ # @api private
11
+ def initialize(transport)
12
+ @transport = transport
13
+ end
14
+
15
+ # Starts watching a flight. The API posts a signed webhook to
16
+ # `webhook_url` each time something about it changes.
17
+ #
18
+ # @param carrier [String] carrier code, for example `TK`
19
+ # @param flight_number [String, Integer] flight number, for example `1848`
20
+ # @param date [String, Date, Time] local departure date at the origin, `YYYY-MM-DD`
21
+ # @param webhook_url [String] where webhooks are posted; a public HTTPS URL
22
+ # @param origin [String, nil] selects a leg, when the flight number flies several that day
23
+ # @param destination [String, nil] narrows the leg further
24
+ # @param flight_id [String, nil] selects a leg by its upstream id
25
+ # @param secret [String, nil] the webhook signing secret. By default the API
26
+ # generates one, returned once as `secret`. Setting the same secret on
27
+ # every subscription lets one receiver verify them all.
28
+ # @param events [Array<String>, nil] change types that trigger a webhook; by default every type does
29
+ # @param delay_threshold_minutes [Integer, nil] ignore delay swings smaller than this, 1 to 240
30
+ # @param headers [Hash{String => String}, nil] extra headers sent with every delivery
31
+ # @param metadata [Hash{String => String}, nil] your own data, echoed back in every webhook
32
+ # @param idempotency_key [String, nil] makes retries safe: repeating the
33
+ # request with the same key within 24 hours returns the original
34
+ # subscription with `reused` true, instead of creating and billing a
35
+ # second one. Sent as `Idempotency-Key`.
36
+ # @param timeout [Numeric, nil] seconds for this request instead of the client's
37
+ # @return [CreatedSubscription]
38
+ # @raise [PaymentRequiredError] when no watched flights are left
39
+ # @raise [BadRequestError] with `legs` set, when the flight number flies
40
+ # several legs that day and neither `origin:` nor `flight_id:` chose one
41
+ def create(carrier:, flight_number:, date:, webhook_url:, origin: nil, destination: nil, flight_id: nil,
42
+ secret: nil, events: nil, delay_threshold_minutes: nil, headers: nil, metadata: nil,
43
+ idempotency_key: nil, timeout: nil)
44
+ body = {
45
+ "carrier" => Transport.required("carrier", carrier),
46
+ "flightNumber" => Transport.required("flight_number", flight_number),
47
+ "date" => Transport.date("date", date),
48
+ "webhookUrl" => Transport.required("webhook_url", webhook_url),
49
+ "origin" => origin,
50
+ "destination" => destination,
51
+ "flightId" => flight_id,
52
+ "secret" => secret,
53
+ "events" => events&.map(&:to_s),
54
+ "delayThresholdMinutes" => delay_threshold_minutes,
55
+ "headers" => headers,
56
+ "metadata" => metadata
57
+ }.compact
58
+ key = idempotency_key.to_s.strip
59
+ request_headers = key.empty? ? nil : { "idempotency-key" => key }
60
+ created = @transport.request("POST", ["subscriptions"], body: body, headers: request_headers, timeout: timeout)
61
+ if created.is_a?(Hash) && created["secret"].is_a?(String) && !created["secretHint"]
62
+ created = created.merge("secretHint" => FlightByte.mask_secret(created["secret"]))
63
+ end
64
+ CreatedSubscription.from_response(created)
65
+ end
66
+
67
+ # One page of your subscriptions, newest first.
68
+ #
69
+ # @param status [String, nil] keeps subscriptions in this status; see {SUBSCRIPTION_STATUSES}
70
+ # @param carrier [String, nil] keeps subscriptions for this carrier
71
+ # @param flight_number [String, Integer, nil] keeps subscriptions for this flight number
72
+ # @param date [String, Date, Time, nil] keeps subscriptions for this date
73
+ # @param limit [Integer, nil] page size; the API clamps it to 1 to 200 and defaults to 25
74
+ # @param offset [Integer, nil] the number of subscriptions to skip
75
+ # @param timeout [Numeric, nil] seconds for this request instead of the client's
76
+ # @return [SubscriptionPage]
77
+ def list(status: nil, carrier: nil, flight_number: nil, date: nil, limit: nil, offset: nil, timeout: nil)
78
+ date = FlightByte.format_date(date) if date.respond_to?(:strftime)
79
+ query = { status: status, carrier: carrier, flightNumber: flight_number, date: date, limit: limit,
80
+ offset: offset }
81
+ SubscriptionPage.from_response(@transport.request("GET", ["subscriptions"], query: query, timeout: timeout))
82
+ end
83
+
84
+ # Every subscription matching the filters, fetched page by page as you
85
+ # iterate. With a block it yields each one; without, it returns an
86
+ # Enumerator, so `first(10)`, `lazy` and `each_slice` fetch only the pages
87
+ # they need. Stop early with `break`; nothing more is fetched.
88
+ #
89
+ # client.subscriptions.list_all(status: "active") do |subscription|
90
+ # puts subscription.flight_key
91
+ # end
92
+ #
93
+ # @param page_size [Integer] page size, 1 to 200
94
+ # @param (see #list)
95
+ # @yieldparam subscription [Subscription]
96
+ # @return [Enumerator<Subscription>, nil]
97
+ def list_all(status: nil, carrier: nil, flight_number: nil, date: nil, offset: nil,
98
+ page_size: DEFAULT_PAGE_SIZE, timeout: nil, &block)
99
+ filters = { status: status, carrier: carrier, flight_number: flight_number, date: date, timeout: timeout }
100
+ pages = Enumerator.new { |yielder| paginate(filters, offset || 0, page_size) { |item| yielder << item } }
101
+ return pages unless block
102
+
103
+ pages.each(&block)
104
+ nil
105
+ end
106
+ alias each list_all
107
+
108
+ # One subscription.
109
+ #
110
+ # @param id [String]
111
+ # @param timeout [Numeric, nil] seconds for this request instead of the client's
112
+ # @return [Subscription]
113
+ # @raise [NotFoundError] when it does not exist or belongs to another key
114
+ def get(id, timeout: nil)
115
+ Subscription.from_response(@transport.request("GET", path(id), timeout: timeout))
116
+ end
117
+
118
+ # Changes a subscription. Only the fields you pass change. `headers` and
119
+ # `metadata` replace the existing maps; the API cannot clear them, so an
120
+ # empty map leaves them as they are.
121
+ #
122
+ # @param id [String]
123
+ # @param webhook_url [String, nil] moves deliveries to another URL
124
+ # @param events [Array<String>, nil] replaces the change type filter
125
+ # @param delay_threshold_minutes [Integer, nil] replaces the delay threshold
126
+ # @param headers [Hash{String => String}, nil] replaces the delivery headers
127
+ # @param metadata [Hash{String => String}, nil] replaces the metadata
128
+ # @param timeout [Numeric, nil] seconds for this request instead of the client's
129
+ # @return [Subscription]
130
+ def update(id, webhook_url: nil, events: nil, delay_threshold_minutes: nil, headers: nil, metadata: nil,
131
+ timeout: nil)
132
+ body = {
133
+ "webhookUrl" => webhook_url,
134
+ "events" => events&.map(&:to_s),
135
+ "delayThresholdMinutes" => delay_threshold_minutes,
136
+ "headers" => headers,
137
+ "metadata" => metadata
138
+ }.compact
139
+ Subscription.from_response(@transport.request("PATCH", path(id), body: body, timeout: timeout))
140
+ end
141
+
142
+ # Stops watching the flight and deletes the subscription for good.
143
+ #
144
+ # @param id [String]
145
+ # @param timeout [Numeric, nil] seconds for this request instead of the client's
146
+ # @return [nil]
147
+ def delete(id, timeout: nil)
148
+ @transport.request("DELETE", path(id), timeout: timeout)
149
+ nil
150
+ end
151
+
152
+ # Pauses polling and deliveries; {#resume} picks up where it left off.
153
+ #
154
+ # @param (see #get)
155
+ # @return [Subscription]
156
+ def pause(id, timeout: nil)
157
+ Subscription.from_response(@transport.request("POST", path(id, "pause"), timeout: timeout))
158
+ end
159
+
160
+ # Resumes a paused or suspended subscription. A completed one cannot be
161
+ # resumed and gives a {BadRequestError}.
162
+ #
163
+ # @param (see #get)
164
+ # @return [Subscription]
165
+ def resume(id, timeout: nil)
166
+ Subscription.from_response(@transport.request("POST", path(id, "resume"), timeout: timeout))
167
+ end
168
+
169
+ # Sends a signed `subscription.test` webhook to the subscription's URL.
170
+ #
171
+ # @param (see #get)
172
+ # @return [String] the event id
173
+ def send_test(id, timeout: nil)
174
+ field(@transport.request("POST", path(id, "test"), timeout: timeout), "eventId")
175
+ end
176
+
177
+ # The latest delivery attempts, newest first.
178
+ #
179
+ # @param id [String]
180
+ # @param limit [Integer, nil] how many to return, 1 to 100; the API's default is 25
181
+ # @param timeout [Numeric, nil] seconds for this request instead of the client's
182
+ # @return [Array<DeliveryRecord>]
183
+ def deliveries(id, limit: nil, timeout: nil)
184
+ response = @transport.request("GET", path(id, "deliveries"), query: { limit: limit }, timeout: timeout)
185
+ DeliveryRecord.list_from_response(response)
186
+ end
187
+
188
+ # The latest events, exactly as they were posted, newest first.
189
+ #
190
+ # @param (see #deliveries)
191
+ # @return [Array<WebhookEvent>]
192
+ def events(id, limit: nil, timeout: nil)
193
+ response = @transport.request("GET", path(id, "events"), query: { limit: limit }, timeout: timeout)
194
+ WebhookEvent.list_from_response(response)
195
+ end
196
+
197
+ # The full webhook signing secret.
198
+ #
199
+ # @param (see #get)
200
+ # @return [String]
201
+ def secret(id, timeout: nil)
202
+ field(@transport.request("GET", path(id, "secret"), timeout: timeout), "secret")
203
+ end
204
+
205
+ # Replaces the webhook signing secret and returns the new one. Deliveries
206
+ # are signed with it from now on, so update your receiver right away.
207
+ #
208
+ # @param (see #get)
209
+ # @return [String]
210
+ def rotate_secret(id, timeout: nil)
211
+ field(@transport.request("POST", path(id, "secret", "rotate"), timeout: timeout), "secret")
212
+ end
213
+
214
+ # Never shows the API key.
215
+ def inspect
216
+ "#<#{self.class.name}>"
217
+ end
218
+
219
+ private
220
+
221
+ def path(id, *rest)
222
+ ["subscriptions", Transport.required("id", id), *rest]
223
+ end
224
+
225
+ def paginate(filters, offset, page_size, &block)
226
+ loop do
227
+ page = list(**filters, limit: page_size, offset: offset)
228
+ items = page.data || []
229
+ items.each(&block)
230
+ following = (page.offset || offset) + items.length
231
+ break if items.empty? || !page.total.is_a?(Integer) || following >= page.total
232
+
233
+ offset = following
234
+ end
235
+ end
236
+
237
+ def field(response, key)
238
+ raise Error, "flightbyte: expected a JSON object with #{key} from the API" unless response.is_a?(Hash)
239
+
240
+ response[key]
241
+ end
242
+ end
243
+ end