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,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module FlightByte
|
|
6
|
+
# Base class of every error this gem raises. Invalid arguments raise
|
|
7
|
+
# `ArgumentError` instead, before anything is sent.
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# A request got no HTTP response: a refused connection, a DNS failure, a TLS
|
|
11
|
+
# failure, a dropped socket, or the timeout ran out. The original exception
|
|
12
|
+
# is in `cause`.
|
|
13
|
+
class ConnectionError < Error
|
|
14
|
+
# @api private
|
|
15
|
+
def initialize(message, timed_out: false)
|
|
16
|
+
super(message)
|
|
17
|
+
@timed_out = timed_out
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Whether the client's own timeout ran out.
|
|
21
|
+
def timed_out?
|
|
22
|
+
@timed_out
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The API answered with a non-2xx status. The readers mirror the API's error
|
|
27
|
+
# body. Rescue the subclass you can handle:
|
|
28
|
+
#
|
|
29
|
+
# begin
|
|
30
|
+
# client.flights.get(carrier: "TK", flight_number: "1", date: "2026-09-08")
|
|
31
|
+
# rescue FlightByte::NotFoundError
|
|
32
|
+
# puts "No such flight that day"
|
|
33
|
+
# rescue FlightByte::RateLimitError => e
|
|
34
|
+
# sleep(e.retry_after || 5)
|
|
35
|
+
# retry
|
|
36
|
+
# end
|
|
37
|
+
class APIError < Error
|
|
38
|
+
# @return [Integer] The HTTP status.
|
|
39
|
+
attr_reader :status
|
|
40
|
+
# @return [Array<String>] Every message the API sent. Validation errors send one per field.
|
|
41
|
+
attr_reader :messages
|
|
42
|
+
# @return [Hash{String => Object}] The decoded error body; empty when it was not a JSON object.
|
|
43
|
+
attr_reader :body
|
|
44
|
+
# @return [String] The body exactly as received.
|
|
45
|
+
attr_reader :raw_body
|
|
46
|
+
# @return [Hash{String => String}] The response headers, with lower-case names.
|
|
47
|
+
attr_reader :headers
|
|
48
|
+
|
|
49
|
+
# @api private
|
|
50
|
+
# Builds the subclass for `status`.
|
|
51
|
+
def self.from(status, reason, raw_body, headers)
|
|
52
|
+
error_class_for(status).new(status, reason, raw_body, headers)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @api private
|
|
56
|
+
def self.error_class_for(status)
|
|
57
|
+
case status
|
|
58
|
+
when 400 then BadRequestError
|
|
59
|
+
when 401 then AuthenticationError
|
|
60
|
+
when 402 then PaymentRequiredError
|
|
61
|
+
when 403 then PermissionDeniedError
|
|
62
|
+
when 404 then NotFoundError
|
|
63
|
+
when 429 then RateLimitError
|
|
64
|
+
when 502 then UpstreamError
|
|
65
|
+
when 500..599 then ServerError
|
|
66
|
+
else UnexpectedStatusError
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @api private
|
|
71
|
+
def initialize(status, reason, raw_body, headers)
|
|
72
|
+
@status = status
|
|
73
|
+
@raw_body = raw_body.to_s.dup.freeze
|
|
74
|
+
@headers = headers.dup.freeze
|
|
75
|
+
@body = decode(@raw_body).freeze
|
|
76
|
+
@messages = read_messages(@body).freeze
|
|
77
|
+
super(build_message(reason.to_s.strip))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A machine-readable reason, when the API sends one, for example
|
|
81
|
+
# `watched_flight_limit` or `account_suspended`.
|
|
82
|
+
#
|
|
83
|
+
# @return [String, nil]
|
|
84
|
+
def code
|
|
85
|
+
string_field("code")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Identifies the request; quote it when contacting support.
|
|
89
|
+
#
|
|
90
|
+
# @return [String, nil]
|
|
91
|
+
def request_id
|
|
92
|
+
string_field("requestId") || headers["x-request-id"]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def build_message(reason)
|
|
98
|
+
# HTTP/2 and some proxies send no reason phrase; the API's own fills in.
|
|
99
|
+
phrase = reason.empty? ? string_field("error").to_s : reason
|
|
100
|
+
label = [status, phrase].reject { |part| part.to_s.empty? }.join(" ")
|
|
101
|
+
message = messages.empty? ? label : "#{label}: #{messages.join("; ")}"
|
|
102
|
+
message += " (request #{request_id})" if request_id
|
|
103
|
+
message
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def decode(raw)
|
|
107
|
+
parsed = JSON.parse(raw)
|
|
108
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
109
|
+
rescue JSON::ParserError, EncodingError
|
|
110
|
+
{}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def read_messages(body)
|
|
114
|
+
case (message = body["message"])
|
|
115
|
+
when String then [message]
|
|
116
|
+
when Array then message.map(&:to_s)
|
|
117
|
+
else []
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def string_field(key)
|
|
122
|
+
value = body[key]
|
|
123
|
+
value.is_a?(String) ? value : nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def number_field(key)
|
|
127
|
+
value = body[key]
|
|
128
|
+
value.is_a?(Numeric) && value.finite? ? value : nil
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# 400: the request was invalid. When a flight number flies several legs that
|
|
133
|
+
# day and no `origin:` chose one, {#legs} lists them.
|
|
134
|
+
class BadRequestError < APIError
|
|
135
|
+
# The legs to choose from; pass one's `origin` or `flight_id` and try again.
|
|
136
|
+
#
|
|
137
|
+
# @return [Array<AmbiguousLeg>]
|
|
138
|
+
def legs
|
|
139
|
+
legs = body["legs"]
|
|
140
|
+
return [] unless legs.is_a?(Array)
|
|
141
|
+
|
|
142
|
+
legs.grep(Hash).map { |leg| AmbiguousLeg.new(leg) }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# 401: the API key is missing, invalid, revoked or expired.
|
|
147
|
+
class AuthenticationError < APIError; end
|
|
148
|
+
|
|
149
|
+
# 402: no watched flights are left. {#code} says why:
|
|
150
|
+
# `watched_flight_limit` (the plan's cap), `overage_cap_reached` (the
|
|
151
|
+
# account's overage cap) or `credits_exhausted` (prepaid credits used up).
|
|
152
|
+
class PaymentRequiredError < APIError
|
|
153
|
+
# Watched flights the usage period allows. Not with `credits_exhausted`.
|
|
154
|
+
# @return [Numeric, nil]
|
|
155
|
+
def limit = number_field("limit")
|
|
156
|
+
|
|
157
|
+
# Watched flights created this usage period. Not with `credits_exhausted`.
|
|
158
|
+
# @return [Numeric, nil]
|
|
159
|
+
def used = number_field("used")
|
|
160
|
+
|
|
161
|
+
# When the usage period, and so the count, resets, ISO 8601.
|
|
162
|
+
# @return [String, nil]
|
|
163
|
+
def resets_at = string_field("resetsAt")
|
|
164
|
+
|
|
165
|
+
# The overage cap in US cents; only with `overage_cap_reached`.
|
|
166
|
+
# @return [Numeric, nil]
|
|
167
|
+
def cap_cents = number_field("capCents")
|
|
168
|
+
|
|
169
|
+
# Prepaid credits left; only with `credits_exhausted`.
|
|
170
|
+
# @return [Numeric, nil]
|
|
171
|
+
def balance = number_field("balance")
|
|
172
|
+
|
|
173
|
+
# When the credits expire or expired, ISO 8601; only with `credits_exhausted`.
|
|
174
|
+
# @return [String, nil]
|
|
175
|
+
def expires_at = string_field("expiresAt")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# 403: the account is suspended (`code` `account_suspended`) or a token
|
|
179
|
+
# lacks the scope (`code` `insufficient_scope`).
|
|
180
|
+
class PermissionDeniedError < APIError; end
|
|
181
|
+
|
|
182
|
+
# 404: the flight did not operate that day, or no such subscription.
|
|
183
|
+
class NotFoundError < APIError; end
|
|
184
|
+
|
|
185
|
+
# 429: a minute, day or month quota is used up.
|
|
186
|
+
class RateLimitError < APIError
|
|
187
|
+
# The window that ran out: `minute`, `day` or `month`.
|
|
188
|
+
#
|
|
189
|
+
# @return [String, nil]
|
|
190
|
+
def window
|
|
191
|
+
string_field("window")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# How many seconds to wait before retrying, from the body's
|
|
195
|
+
# `retryAfterSeconds` or the `Retry-After` header.
|
|
196
|
+
#
|
|
197
|
+
# @return [Numeric, nil]
|
|
198
|
+
def retry_after
|
|
199
|
+
from_body = number_field("retryAfterSeconds")
|
|
200
|
+
return from_body if from_body
|
|
201
|
+
|
|
202
|
+
header = headers["retry-after"].to_s.strip
|
|
203
|
+
header.match?(/\A\d+\z/) ? Integer(header, 10) : nil
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# 5xx: the API failed. {#request_id} is what support asks for.
|
|
208
|
+
class ServerError < APIError; end
|
|
209
|
+
|
|
210
|
+
# 502: the upstream flight data provider failed. Safe to retry after a short
|
|
211
|
+
# wait. It is also a {ServerError}.
|
|
212
|
+
class UpstreamError < ServerError; end
|
|
213
|
+
|
|
214
|
+
# Any other non-2xx status, including a redirect this client does not
|
|
215
|
+
# follow: one to another origin, or one for a request other than GET or
|
|
216
|
+
# HEAD. The target is in {#location}.
|
|
217
|
+
class UnexpectedStatusError < APIError
|
|
218
|
+
# Where a 3xx pointed, when it did.
|
|
219
|
+
#
|
|
220
|
+
# @return [String, nil]
|
|
221
|
+
def location
|
|
222
|
+
headers["location"]
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The webhook signature did not verify.
|
|
227
|
+
class WebhookSignatureError < Error; end
|
|
228
|
+
end
|