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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE +21 -0
- data/README.md +360 -0
- data/lib/flightbyte/client.rb +92 -0
- data/lib/flightbyte/errors.rb +228 -0
- data/lib/flightbyte/models.rb +546 -0
- data/lib/flightbyte/object.rb +278 -0
- data/lib/flightbyte/rate_limit.rb +53 -0
- data/lib/flightbyte/resources/countries.rb +47 -0
- data/lib/flightbyte/resources/flights.rb +61 -0
- data/lib/flightbyte/resources/subscriptions.rb +243 -0
- data/lib/flightbyte/transport.rb +223 -0
- data/lib/flightbyte/values.rb +67 -0
- data/lib/flightbyte/version.rb +6 -0
- data/lib/flightbyte/webhook/rack_app.rb +114 -0
- data/lib/flightbyte/webhook.rb +186 -0
- data/lib/flightbyte.rb +79 -0
- data/sig/flightbyte.rbs +414 -0
- metadata +66 -0
|
@@ -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
|