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,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "timeout"
|
|
7
|
+
require "uri"
|
|
8
|
+
require "zlib"
|
|
9
|
+
|
|
10
|
+
module FlightByte
|
|
11
|
+
# Sends authenticated requests and turns error statuses into errors.
|
|
12
|
+
#
|
|
13
|
+
# @api private Reach the API through {Client}.
|
|
14
|
+
class Transport
|
|
15
|
+
# How many same-origin redirects a GET or HEAD follows.
|
|
16
|
+
MAX_REDIRECTS = 5
|
|
17
|
+
|
|
18
|
+
# Every way a request can fail without an HTTP response.
|
|
19
|
+
TRANSPORT_ERRORS = [
|
|
20
|
+
SocketError,
|
|
21
|
+
SystemCallError,
|
|
22
|
+
IOError,
|
|
23
|
+
Timeout::Error,
|
|
24
|
+
OpenSSL::OpenSSLError,
|
|
25
|
+
Net::ProtocolError,
|
|
26
|
+
Net::HTTPBadResponse,
|
|
27
|
+
Net::HTTPHeaderSyntaxError,
|
|
28
|
+
Zlib::Error
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# A response, as the transport hands it back.
|
|
32
|
+
Response = Struct.new(:status, :reason, :headers, :body, keyword_init: true)
|
|
33
|
+
private_constant :Response
|
|
34
|
+
|
|
35
|
+
attr_reader :base_url, :timeout
|
|
36
|
+
|
|
37
|
+
def initialize(api_key:, base_url:, timeout:, user_agent:)
|
|
38
|
+
@api_key = api_key
|
|
39
|
+
@base_url = base_url
|
|
40
|
+
@timeout = timeout
|
|
41
|
+
@user_agent = user_agent
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
@last_rate_limit = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The quota state from the latest response that carried one.
|
|
47
|
+
def last_rate_limit
|
|
48
|
+
@mutex.synchronize { @last_rate_limit }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Builds the URL for `path` under the base URL, each segment escaped.
|
|
52
|
+
# `nil` and empty query values are left out.
|
|
53
|
+
def url(path, query = nil)
|
|
54
|
+
url = "#{@base_url}/#{path.map { |segment| Transport.escape(segment) }.join("/")}"
|
|
55
|
+
pairs = (query || {}).filter_map do |key, value|
|
|
56
|
+
[key.to_s, value.to_s] unless value.nil? || value == ""
|
|
57
|
+
end
|
|
58
|
+
pairs.empty? ? url : "#{url}?#{URI.encode_www_form(pairs)}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Sends a request and returns the decoded JSON, or `nil` for an empty body.
|
|
62
|
+
def request(method, path, query: nil, body: nil, headers: nil, timeout: nil)
|
|
63
|
+
timeout = timeout.nil? ? @timeout : Transport.check_timeout(timeout)
|
|
64
|
+
target = url(path, query)
|
|
65
|
+
request_headers = { "x-api-key" => @api_key, "accept" => "application/json", "user-agent" => @user_agent }
|
|
66
|
+
request_headers.merge!(headers) if headers
|
|
67
|
+
payload = nil
|
|
68
|
+
if body.nil?
|
|
69
|
+
request_headers["content-length"] = "0" if %w[POST PUT PATCH].include?(method)
|
|
70
|
+
else
|
|
71
|
+
request_headers["content-type"] = "application/json"
|
|
72
|
+
payload = JSON.generate(body)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
response = perform(method, target, request_headers, payload, timeout)
|
|
76
|
+
handle(method, target, response)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Never shows the API key.
|
|
80
|
+
def inspect
|
|
81
|
+
"#<#{self.class.name} base_url=#{@base_url.inspect}>"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class << self
|
|
85
|
+
# Escapes one path segment, so an id like `../admin?x=1` stays one segment.
|
|
86
|
+
def escape(segment)
|
|
87
|
+
segment = segment.to_s
|
|
88
|
+
# `.` and `..` would be read as a step up or across the path.
|
|
89
|
+
return segment.gsub(".", "%2E") if segment.match?(/\A\.{1,2}\z/)
|
|
90
|
+
|
|
91
|
+
segment.b.gsub(/[^A-Za-z0-9\-._~]/n) { |byte| format("%%%02X", byte.ord) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Rejects an empty path parameter before it turns into a request for the wrong URL.
|
|
95
|
+
def required(name, value)
|
|
96
|
+
value = value.to_s if value.is_a?(Integer) || value.is_a?(Symbol)
|
|
97
|
+
raise ArgumentError, "flightbyte: #{name} is required." unless value.is_a?(String) && !value.strip.empty?
|
|
98
|
+
|
|
99
|
+
value
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A flight date: a `YYYY-MM-DD` string, or a Date or Time to format.
|
|
103
|
+
def date(name, value)
|
|
104
|
+
value.respond_to?(:strftime) ? FlightByte.format_date(value) : required(name, value)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def check_timeout(value)
|
|
108
|
+
unless value.is_a?(Numeric) && value.real? && value.finite? && value >= 0
|
|
109
|
+
raise ArgumentError, "flightbyte: timeout must be a number of seconds, 0 or more; got #{value.inspect}."
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
value
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def perform(method, target, headers, payload, timeout)
|
|
119
|
+
with_deadline(timeout) do
|
|
120
|
+
deadline = timeout.positive? ? clock + timeout : nil
|
|
121
|
+
current = target
|
|
122
|
+
hops = 0
|
|
123
|
+
loop do
|
|
124
|
+
response = exchange(method, URI(current), headers, payload, deadline)
|
|
125
|
+
following = followable_redirect(response, current, method)
|
|
126
|
+
return response if following.nil? || hops >= MAX_REDIRECTS
|
|
127
|
+
|
|
128
|
+
hops += 1
|
|
129
|
+
current = following
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
rescue Timeout::Error
|
|
133
|
+
raise ConnectionError.new("#{method} #{target} timed out after #{seconds(timeout)}.", timed_out: true)
|
|
134
|
+
rescue *TRANSPORT_ERRORS => e
|
|
135
|
+
raise ConnectionError, "#{method} #{target} failed: #{e.message} (#{e.class})"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The overall deadline: it bounds the whole exchange, redirects and the
|
|
139
|
+
# response body included, which the per-read timeouts alone cannot.
|
|
140
|
+
def with_deadline(timeout, &)
|
|
141
|
+
timeout.positive? ? Timeout.timeout(timeout, &) : yield
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def exchange(method, uri, headers, payload, deadline)
|
|
145
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
146
|
+
http.use_ssl = uri.scheme == "https"
|
|
147
|
+
# Net::HTTP retries an idempotent request once on its own; a retry is
|
|
148
|
+
# the caller's call, and it would stretch the timeout.
|
|
149
|
+
http.max_retries = 0
|
|
150
|
+
remaining = deadline && [deadline - clock, 0.001].max
|
|
151
|
+
http.open_timeout = remaining
|
|
152
|
+
http.read_timeout = remaining
|
|
153
|
+
http.write_timeout = remaining
|
|
154
|
+
|
|
155
|
+
# Built as a generic request so that one without a payload has no body
|
|
156
|
+
# at all. Before Ruby 4.0, Net::HTTP::Post and friends give a body-less
|
|
157
|
+
# request an empty body and then a form content type, with a warning.
|
|
158
|
+
request = Net::HTTPGenericRequest.new(method, !payload.nil?, method != "HEAD", uri.request_uri, headers)
|
|
159
|
+
http.start do |connection|
|
|
160
|
+
response = connection.request(request, payload)
|
|
161
|
+
Response.new(
|
|
162
|
+
status: response.code.to_i,
|
|
163
|
+
reason: response.message.to_s,
|
|
164
|
+
headers: response.each_header.to_h.freeze,
|
|
165
|
+
# Invalid bytes become U+FFFD, as a browser or Node would decode them.
|
|
166
|
+
body: response.body.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Where to go next when `response` is a redirect this client may follow: a
|
|
172
|
+
# GET or HEAD, to the same origin (scheme, host and port). Anything else
|
|
173
|
+
# reaches the caller as an error, so the API key, sent with every request,
|
|
174
|
+
# never reaches another server or travels in the clear, and a write is
|
|
175
|
+
# never replayed.
|
|
176
|
+
def followable_redirect(response, from, method)
|
|
177
|
+
return nil unless (300..399).cover?(response.status) && %w[GET HEAD].include?(method)
|
|
178
|
+
|
|
179
|
+
location = response.headers["location"].to_s
|
|
180
|
+
return nil if location.empty?
|
|
181
|
+
|
|
182
|
+
target = URI.join(from, location)
|
|
183
|
+
same_origin?(URI(from), target) ? target.to_s : nil
|
|
184
|
+
rescue URI::Error
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def same_origin?(from, target)
|
|
189
|
+
target.is_a?(URI::HTTP) && !target.host.to_s.empty? &&
|
|
190
|
+
from.scheme.downcase == target.scheme.to_s.downcase &&
|
|
191
|
+
from.host.downcase == target.host.downcase &&
|
|
192
|
+
from.port == target.port
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def handle(method, target, response)
|
|
196
|
+
rate_limit = RateLimit.parse(response.headers)
|
|
197
|
+
@mutex.synchronize { @last_rate_limit = rate_limit } if rate_limit
|
|
198
|
+
|
|
199
|
+
unless (200..299).cover?(response.status)
|
|
200
|
+
raise APIError.from(response.status, response.reason, response.body, response.headers)
|
|
201
|
+
end
|
|
202
|
+
return nil if response.status == 204 || response.body.strip.empty?
|
|
203
|
+
|
|
204
|
+
begin
|
|
205
|
+
JSON.parse(response.body)
|
|
206
|
+
rescue JSON::ParserError, EncodingError
|
|
207
|
+
text = response.body
|
|
208
|
+
preview = text.length > 200 ? "#{text[0, 200]}..." : text
|
|
209
|
+
raise Error, "Expected JSON from #{method} #{target}, got: #{preview}"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def clock
|
|
214
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def seconds(value)
|
|
218
|
+
value = value.to_i if value.is_a?(Float) && value == value.to_i
|
|
219
|
+
"#{value} #{value == 1 ? "second" : "seconds"}"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
private_constant :Transport
|
|
223
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlightByte
|
|
4
|
+
# The values the API's string enums take, as this version knows them. The
|
|
5
|
+
# API may add more: a value added after this release still arrives as a
|
|
6
|
+
# plain `String`, so nothing breaks. Compare with strings, or check
|
|
7
|
+
# `FLIGHT_PHASES.include?(phase)` to see whether this version knows a value.
|
|
8
|
+
|
|
9
|
+
# Every flight phase this version knows, in the API's order.
|
|
10
|
+
FLIGHT_PHASES = %w[
|
|
11
|
+
scheduled boarding departed en_route landed arrived cancelled diverted redirected unknown
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
# The phases after which nothing about the flight changes any more.
|
|
15
|
+
TERMINAL_PHASES = %w[arrived cancelled diverted redirected].freeze
|
|
16
|
+
|
|
17
|
+
# Every subscription status this version knows.
|
|
18
|
+
#
|
|
19
|
+
# `active` polls and delivers, `pending` waits for the flight to be
|
|
20
|
+
# published upstream, `paused` was paused by you, `completed` finished after
|
|
21
|
+
# the flight did, `suspended` was disabled by the API, for example after
|
|
22
|
+
# repeated delivery failures.
|
|
23
|
+
SUBSCRIPTION_STATUSES = %w[active pending paused completed suspended].freeze
|
|
24
|
+
|
|
25
|
+
# Every flight change type this version knows, in the API's order: the kind
|
|
26
|
+
# of change a `flight.updated` webhook reports, and what a subscription's
|
|
27
|
+
# `events` filter is made of.
|
|
28
|
+
FLIGHT_CHANGE_TYPES = %w[
|
|
29
|
+
status.changed
|
|
30
|
+
flight.departed
|
|
31
|
+
flight.arrived
|
|
32
|
+
flight.cancelled
|
|
33
|
+
flight.diverted
|
|
34
|
+
flight.reinstated
|
|
35
|
+
departure.gate.changed
|
|
36
|
+
departure.terminal.changed
|
|
37
|
+
arrival.gate.changed
|
|
38
|
+
arrival.terminal.changed
|
|
39
|
+
arrival.baggage.changed
|
|
40
|
+
departure.scheduled.changed
|
|
41
|
+
departure.estimated.changed
|
|
42
|
+
departure.actual.changed
|
|
43
|
+
arrival.scheduled.changed
|
|
44
|
+
arrival.estimated.changed
|
|
45
|
+
arrival.actual.changed
|
|
46
|
+
delay.increased
|
|
47
|
+
delay.decreased
|
|
48
|
+
aircraft.changed
|
|
49
|
+
aircraft.tail.changed
|
|
50
|
+
route.changed
|
|
51
|
+
codeshares.changed
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
# Every webhook event type this version knows.
|
|
55
|
+
WEBHOOK_EVENT_TYPES = %w[
|
|
56
|
+
subscription.created
|
|
57
|
+
subscription.activated
|
|
58
|
+
subscription.test
|
|
59
|
+
subscription.completed
|
|
60
|
+
subscription.suspended
|
|
61
|
+
flight.updated
|
|
62
|
+
].freeze
|
|
63
|
+
|
|
64
|
+
# Every delivery status this version knows: the outcome of one webhook
|
|
65
|
+
# delivery attempt.
|
|
66
|
+
DELIVERY_STATUSES = %w[delivered failed].freeze
|
|
67
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlightByte
|
|
4
|
+
module Webhook
|
|
5
|
+
# A Rack app that receives FlightByte webhooks. It needs no Rack gem, so
|
|
6
|
+
# it runs under anything that speaks Rack: Rails, Sinatra, Hanami, Roda,
|
|
7
|
+
# or a bare `config.ru` on Puma.
|
|
8
|
+
#
|
|
9
|
+
# # config/routes.rb
|
|
10
|
+
# mount FlightByte::Webhook::RackApp.new(ENV.fetch("FLIGHTBYTE_WEBHOOK_SECRET")) { |event|
|
|
11
|
+
# FlightEventJob.perform_later(event.to_h)
|
|
12
|
+
# }, at: "/hooks/flightbyte"
|
|
13
|
+
#
|
|
14
|
+
# It reads up to {MAX_BODY_BYTES}, verifies the signature, decodes the
|
|
15
|
+
# event and calls the block with it and the Rack env. It answers 405 to
|
|
16
|
+
# anything but POST, 413 to a larger body, 401 when the signature does not
|
|
17
|
+
# verify, 400 when the body is not an event, 500 when the block raises (so
|
|
18
|
+
# FlightByte retries) and 200 otherwise. Retries carry the same event id:
|
|
19
|
+
# deduplicate on `event.id`.
|
|
20
|
+
class RackApp
|
|
21
|
+
# @param secret [String] the subscription's signing secret
|
|
22
|
+
# @param tolerance [Numeric] how far the signing time may be from now, in seconds; `0` skips the check
|
|
23
|
+
# @param now [Time, Numeric, nil] the time to check against instead of now; useful in tests
|
|
24
|
+
# @param on_error [#call, nil] called with the error, the event and the
|
|
25
|
+
# Rack env when the block raises, after the 500 is decided. Defaults to
|
|
26
|
+
# `warn`. Whatever it raises is ignored, so the answer stays 500.
|
|
27
|
+
# @yieldparam event [WebhookEvent]
|
|
28
|
+
# @yieldparam env [Hash] the Rack env
|
|
29
|
+
# @raise [ArgumentError] right away when `secret` is empty, so an unset
|
|
30
|
+
# environment variable fails at boot instead of accepting forged events,
|
|
31
|
+
# or when no block is given
|
|
32
|
+
def initialize(secret, tolerance: DEFAULT_TOLERANCE, now: nil, on_error: nil, &handler)
|
|
33
|
+
Webhook.check_secret(secret)
|
|
34
|
+
raise ArgumentError, "flightbyte: RackApp needs a block to handle each event." unless handler
|
|
35
|
+
unless on_error.nil? || on_error.respond_to?(:call)
|
|
36
|
+
raise ArgumentError, "flightbyte: on_error must respond to call."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@secret = secret.dup.freeze
|
|
40
|
+
@tolerance = tolerance
|
|
41
|
+
@now = now
|
|
42
|
+
@on_error = on_error || method(:report)
|
|
43
|
+
@handler = handler
|
|
44
|
+
freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Handles one Rack request.
|
|
48
|
+
#
|
|
49
|
+
# @param env [Hash] the Rack env
|
|
50
|
+
# @return [Array(Integer, Hash, Array<String>)]
|
|
51
|
+
def call(env)
|
|
52
|
+
return reply(405, "method not allowed", "allow" => "POST") unless env["REQUEST_METHOD"] == "POST"
|
|
53
|
+
|
|
54
|
+
payload = read_body(env)
|
|
55
|
+
return reply(413, "body too large") if payload.nil?
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
event = Webhook.construct_event(payload, env[SIGNATURE_ENV_KEY], @secret, tolerance: @tolerance, now: @now)
|
|
59
|
+
rescue WebhookSignatureError
|
|
60
|
+
return reply(401, "invalid signature")
|
|
61
|
+
rescue Error
|
|
62
|
+
return reply(400, "invalid event")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
@handler.call(event, env)
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
notify(e, event, env)
|
|
69
|
+
return reply(500, "handler failed")
|
|
70
|
+
end
|
|
71
|
+
reply(200, "ok")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Never shows the signing secret.
|
|
75
|
+
def inspect
|
|
76
|
+
"#<#{self.class.name}>"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# The body, or nil when it is larger than MAX_BODY_BYTES.
|
|
82
|
+
def read_body(env)
|
|
83
|
+
declared = env["CONTENT_LENGTH"].to_s
|
|
84
|
+
return nil if declared.match?(/\A\d+\z/) && declared.to_i > MAX_BODY_BYTES
|
|
85
|
+
|
|
86
|
+
input = env["rack.input"]
|
|
87
|
+
return +"" if input.nil?
|
|
88
|
+
|
|
89
|
+
body = +"".b
|
|
90
|
+
while (chunk = input.read(16 * 1024))
|
|
91
|
+
body << chunk
|
|
92
|
+
return nil if body.bytesize > MAX_BODY_BYTES
|
|
93
|
+
end
|
|
94
|
+
body
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def notify(error, event, env)
|
|
98
|
+
@on_error.call(error, event, env)
|
|
99
|
+
rescue StandardError
|
|
100
|
+
# A failing reporter must not change the answer.
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def report(error, event, _env)
|
|
105
|
+
warn "flightbyte: the webhook handler failed for event #{event.id} (#{event.type}): " \
|
|
106
|
+
"#{error.full_message(highlight: false)}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def reply(status, text, headers = {})
|
|
110
|
+
[status, { "content-type" => "text/plain; charset=utf-8" }.merge(headers), [text]]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module FlightByte
|
|
7
|
+
# Verifies and decodes the webhooks FlightByte posts to your receiver.
|
|
8
|
+
#
|
|
9
|
+
# Each delivery carries an `x-flightbyte-signature` header of the form
|
|
10
|
+
# `t=<unix seconds>,v1=<hex>`, where the hex is HMAC-SHA256 of
|
|
11
|
+
# `"<t>.<raw body>"` keyed with the subscription's signing secret.
|
|
12
|
+
#
|
|
13
|
+
# event = FlightByte::Webhook.construct_event(
|
|
14
|
+
# request.raw_post,
|
|
15
|
+
# request.headers["X-FlightByte-Signature"],
|
|
16
|
+
# ENV.fetch("FLIGHTBYTE_WEBHOOK_SECRET")
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# Pass the body exactly as received: JSON parsed and serialized again no
|
|
20
|
+
# longer matches the signature. Keep the signing secret on your server.
|
|
21
|
+
module Webhook
|
|
22
|
+
# Header carrying the signature.
|
|
23
|
+
SIGNATURE_HEADER = "x-flightbyte-signature"
|
|
24
|
+
# Header carrying the signing time, in unix seconds.
|
|
25
|
+
TIMESTAMP_HEADER = "x-flightbyte-timestamp"
|
|
26
|
+
# Header carrying the event id.
|
|
27
|
+
EVENT_ID_HEADER = "x-flightbyte-event-id"
|
|
28
|
+
# Header carrying the event type.
|
|
29
|
+
EVENT_TYPE_HEADER = "x-flightbyte-event-type"
|
|
30
|
+
# Header carrying the subscription id.
|
|
31
|
+
SUBSCRIPTION_ID_HEADER = "x-flightbyte-subscription-id"
|
|
32
|
+
# Header carrying the delivery attempt number, starting at 1.
|
|
33
|
+
DELIVERY_ATTEMPT_HEADER = "x-flightbyte-delivery-attempt"
|
|
34
|
+
# The Rack env key of {SIGNATURE_HEADER}, for `request.env[...]`.
|
|
35
|
+
SIGNATURE_ENV_KEY = "HTTP_X_FLIGHTBYTE_SIGNATURE"
|
|
36
|
+
# How far the signing time may be from now by default, in seconds: 5 minutes.
|
|
37
|
+
DEFAULT_TOLERANCE = 300
|
|
38
|
+
# The largest body {RackApp} reads: 1 MiB.
|
|
39
|
+
MAX_BODY_BYTES = 1024 * 1024
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# Checks `signature` (the `x-flightbyte-signature` header) against the
|
|
43
|
+
# raw body `payload` and the subscription's `secret`.
|
|
44
|
+
#
|
|
45
|
+
# @param payload [String] the body exactly as received, for example Rails'
|
|
46
|
+
# `request.raw_post` or Rack's `env["rack.input"].read`
|
|
47
|
+
# @param signature [String, nil] the `x-flightbyte-signature` header
|
|
48
|
+
# @param secret [String] the subscription's signing secret
|
|
49
|
+
# @param tolerance [Numeric] how far the signing time may be from now, in
|
|
50
|
+
# seconds; `0` skips the check
|
|
51
|
+
# @param now [Time, Numeric, nil] the time to check against instead of
|
|
52
|
+
# now, as a Time or unix seconds; useful in tests
|
|
53
|
+
# @return [true]
|
|
54
|
+
# @raise [WebhookSignatureError] when the signature does not verify
|
|
55
|
+
# @raise [ArgumentError] when `secret` is empty: anyone can sign with an
|
|
56
|
+
# empty key, so it usually means the secret was not configured
|
|
57
|
+
def verify(payload, signature, secret, tolerance: DEFAULT_TOLERANCE, now: nil)
|
|
58
|
+
check_secret(secret)
|
|
59
|
+
unless tolerance.is_a?(Numeric) && tolerance.real?
|
|
60
|
+
raise ArgumentError, "flightbyte: tolerance must be a number of seconds; got #{tolerance.inspect}."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
body = bytes(payload)
|
|
64
|
+
timestamp, signatures = parse_signature(signature)
|
|
65
|
+
if tolerance.positive?
|
|
66
|
+
current = unix(now || Time.now)
|
|
67
|
+
# Compared against both edges, so an extreme timestamp cannot slip through.
|
|
68
|
+
if timestamp.to_i < current - tolerance || timestamp.to_i > current + tolerance
|
|
69
|
+
raise WebhookSignatureError, "The signature timestamp is outside the tolerance window."
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
expected = hmac(secret, timestamp, body)
|
|
74
|
+
matched = signatures.any? do |candidate|
|
|
75
|
+
candidate.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(candidate, expected)
|
|
76
|
+
end
|
|
77
|
+
raise WebhookSignatureError, "The signature does not match the payload." unless matched
|
|
78
|
+
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Whether `signature` verifies; see {.verify}.
|
|
83
|
+
#
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
# @raise [ArgumentError] when `secret` is empty
|
|
86
|
+
def valid?(payload, signature, secret, tolerance: DEFAULT_TOLERANCE, now: nil)
|
|
87
|
+
verify(payload, signature, secret, tolerance: tolerance, now: now)
|
|
88
|
+
rescue WebhookSignatureError
|
|
89
|
+
false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Verifies the signature, then decodes the event.
|
|
93
|
+
#
|
|
94
|
+
# @param (see .verify)
|
|
95
|
+
# @return [WebhookEvent]
|
|
96
|
+
# @raise [WebhookSignatureError] when the signature does not verify
|
|
97
|
+
# @raise [Error] when the verified body is not a webhook event
|
|
98
|
+
# @raise [ArgumentError] when `secret` is empty
|
|
99
|
+
def construct_event(payload, signature, secret, tolerance: DEFAULT_TOLERANCE, now: nil)
|
|
100
|
+
verify(payload, signature, secret, tolerance: tolerance, now: now)
|
|
101
|
+
begin
|
|
102
|
+
# As text, invalid bytes replaced, the way the API's other clients decode it.
|
|
103
|
+
event = JSON.parse(bytes(payload).force_encoding(Encoding::UTF_8).scrub)
|
|
104
|
+
rescue JSON::ParserError, EncodingError
|
|
105
|
+
raise Error, "The webhook body is not JSON."
|
|
106
|
+
end
|
|
107
|
+
unless event.is_a?(Hash) && event["id"].is_a?(String) && event["type"].is_a?(String)
|
|
108
|
+
raise Error, "The webhook body is not a FlightByte event."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
WebhookEvent.from_response(event)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Signs `payload` the way the API does and returns the
|
|
115
|
+
# `x-flightbyte-signature` header value. Useful for testing your receiver.
|
|
116
|
+
#
|
|
117
|
+
# @param payload [String]
|
|
118
|
+
# @param secret [String]
|
|
119
|
+
# @param timestamp [Time, Numeric, nil] the signing time, as a Time or unix seconds; now by default
|
|
120
|
+
# @return [String]
|
|
121
|
+
# @raise [ArgumentError] when `secret` is empty
|
|
122
|
+
def sign(payload, secret, timestamp: nil)
|
|
123
|
+
check_secret(secret)
|
|
124
|
+
seconds = unix(timestamp || Time.now).to_s
|
|
125
|
+
"t=#{seconds},v1=#{hmac(secret, seconds, bytes(payload))}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @api private
|
|
129
|
+
# Raises ArgumentError for an empty or blank secret.
|
|
130
|
+
def check_secret(secret)
|
|
131
|
+
return if secret.is_a?(String) && !secret.strip.empty?
|
|
132
|
+
|
|
133
|
+
raise ArgumentError,
|
|
134
|
+
"flightbyte: the webhook signing secret is empty. Anyone can sign with an empty key; " \
|
|
135
|
+
"set the subscription's secret."
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
# Splits `t=<seconds>,v1=<hex>[,v1=<hex>...]`.
|
|
141
|
+
def parse_signature(header)
|
|
142
|
+
# As bytes, so a header that is not valid text cannot raise anything else.
|
|
143
|
+
header = header.to_s.b
|
|
144
|
+
raise WebhookSignatureError, "The signature header is missing." if header.strip.empty?
|
|
145
|
+
|
|
146
|
+
timestamp = nil
|
|
147
|
+
signatures = []
|
|
148
|
+
header.split(",").each do |part|
|
|
149
|
+
key, separator, value = part.partition("=")
|
|
150
|
+
next if separator.empty? || key.strip.empty?
|
|
151
|
+
|
|
152
|
+
case key.strip
|
|
153
|
+
when "t" then timestamp = value.strip
|
|
154
|
+
when "v1" then signatures << value.strip.downcase
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
unless timestamp&.match?(/\A\d{1,15}\z/)
|
|
158
|
+
raise WebhookSignatureError, "The signature header has no valid timestamp."
|
|
159
|
+
end
|
|
160
|
+
raise WebhookSignatureError, "The signature header has no v1 signature." if signatures.empty?
|
|
161
|
+
|
|
162
|
+
[timestamp, signatures]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def hmac(secret, timestamp, body)
|
|
166
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret.b, "#{timestamp}.".b + body)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def bytes(payload)
|
|
170
|
+
unless payload.respond_to?(:to_str)
|
|
171
|
+
raise TypeError, "flightbyte: the webhook payload must be the raw body as a String, got #{payload.class}."
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
payload.to_str.b
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def unix(time)
|
|
178
|
+
case time
|
|
179
|
+
when Time then time.to_i
|
|
180
|
+
when Numeric then time.floor
|
|
181
|
+
else raise ArgumentError, "flightbyte: expected a Time or unix seconds, got #{time.inspect}."
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
data/lib/flightbyte.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "flightbyte/version"
|
|
7
|
+
require_relative "flightbyte/values"
|
|
8
|
+
require_relative "flightbyte/errors"
|
|
9
|
+
require_relative "flightbyte/object"
|
|
10
|
+
require_relative "flightbyte/models"
|
|
11
|
+
require_relative "flightbyte/rate_limit"
|
|
12
|
+
require_relative "flightbyte/transport"
|
|
13
|
+
require_relative "flightbyte/resources/flights"
|
|
14
|
+
require_relative "flightbyte/resources/subscriptions"
|
|
15
|
+
require_relative "flightbyte/resources/countries"
|
|
16
|
+
require_relative "flightbyte/client"
|
|
17
|
+
require_relative "flightbyte/webhook"
|
|
18
|
+
require_relative "flightbyte/webhook/rack_app"
|
|
19
|
+
|
|
20
|
+
# The official client for the FlightByte flight status API: look flights up,
|
|
21
|
+
# watch them with signed webhooks and verify every delivery.
|
|
22
|
+
#
|
|
23
|
+
# require "flightbyte"
|
|
24
|
+
#
|
|
25
|
+
# client = FlightByte::Client.new(api_key: ENV.fetch("FLIGHTBYTE_API_KEY"))
|
|
26
|
+
# flight = client.flights.get(carrier: "TK", flight_number: "1848", date: "2026-09-08")
|
|
27
|
+
# flight.status.label # => "Scheduled"
|
|
28
|
+
#
|
|
29
|
+
# Keep API keys on a server you control. A key shipped inside a browser or
|
|
30
|
+
# mobile app can be extracted by anyone who has the app.
|
|
31
|
+
module FlightByte
|
|
32
|
+
class << self
|
|
33
|
+
# Formats a date as the `YYYY-MM-DD` the API expects for flight dates.
|
|
34
|
+
#
|
|
35
|
+
# Only the calendar fields are used, in the value's own time zone, so pass
|
|
36
|
+
# the local departure date at the origin airport: a `Date`, or a `Time`
|
|
37
|
+
# already in the airport's offset (`time.getlocal("+03:00")`, or
|
|
38
|
+
# `time.in_time_zone("Europe/Istanbul")` with Active Support).
|
|
39
|
+
#
|
|
40
|
+
# @param value [Date, Time, DateTime, #strftime]
|
|
41
|
+
# @return [String]
|
|
42
|
+
# @raise [ArgumentError] when `value` is not a date or a time
|
|
43
|
+
def format_date(value)
|
|
44
|
+
unless value.respond_to?(:strftime)
|
|
45
|
+
raise ArgumentError, "flightbyte: format_date needs a Date or a Time, got #{value.inspect}."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
value.strftime("%Y-%m-%d")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Whether the flight is finished: arrived, cancelled, diverted or redirected.
|
|
52
|
+
#
|
|
53
|
+
# @param phase [String, nil] a {FlightStatusInfo#phase}
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def terminal_phase?(phase)
|
|
56
|
+
TERMINAL_PHASES.include?(phase.to_s)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The best known gate time: actual, else estimated, else scheduled.
|
|
60
|
+
#
|
|
61
|
+
# @param times [EndpointTimes, Hash, nil] a {FlightEndpoint#times}
|
|
62
|
+
# @return [FlightTime, Hash, nil]
|
|
63
|
+
def best_gate_time(times)
|
|
64
|
+
return nil if times.nil?
|
|
65
|
+
|
|
66
|
+
times["actualGate"] || times["estimatedGate"] || times["scheduledGate"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Masks a signing secret the way the API does: the first 11 characters,
|
|
70
|
+
# `...`, and the last 4. Short secrets are returned as they are.
|
|
71
|
+
#
|
|
72
|
+
# @param secret [String]
|
|
73
|
+
# @return [String]
|
|
74
|
+
def mask_secret(secret)
|
|
75
|
+
secret = secret.to_s
|
|
76
|
+
secret.length <= 15 ? secret : "#{secret[0, 11]}...#{secret[-4..]}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|