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,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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlightByte
4
+ # This gem's version. It is sent in the `User-Agent` header of every request.
5
+ VERSION = "1.0.0"
6
+ 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