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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a19d6c08a8b73076155e3b6b97f7c9e6852ad531d3951ce24af8472f46ef1c2e
|
|
4
|
+
data.tar.gz: 0c6acf180d939f3244d7431075a9556d6b410388fd51d8496798c9f4e4dc04e9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 12ab3755dd8a4b351f01f092ff107290126d6c37be916c76cd059aac57e09fe5d8bf3a61d8c03f1d098b1330bec6907be0f26f4917b5fa43d6c5fc766b7937fc
|
|
7
|
+
data.tar.gz: 232317a622ea9ec53bc71359efb11a70903a00ec89dadfc3966ea6ccecb0607748b50296dd6751ea7a44265cb06cb511f1a3779edfd9b65f5576f840619385e5
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
## 1.0.0
|
|
2
|
+
|
|
3
|
+
- First release.
|
|
4
|
+
- `FlightByte::Client` with `flights` (`get`, `timeline`), `subscriptions` (`create`, `list`, `list_all`, `get`, `update`, `delete`, `pause`, `resume`, `send_test`, `deliveries`, `events`, `secret`, `rotate_secret`) and `countries` (`list`, `get`, `flag_url`). Every method takes a `timeout:` of its own.
|
|
5
|
+
- A frozen, read-only class for every response, such as `FlightByte::FlightSnapshot` and `FlightByte::Subscription`, checked against the API's OpenAPI document: snake_case readers, `[]` with the API's own keys, pattern matching, and `to_h` with the JSON exactly as received. Fields and enum values added to the API later read as they arrive.
|
|
6
|
+
- `FlightByte::Error` and a subclass for each error status, including the `credits_exhausted` reason of a 402 with its `balance` and `expires_at`, and `FlightByte::ConnectionError` for every request that got no response.
|
|
7
|
+
- `FlightByte::Webhook.verify`, `valid?`, `construct_event` and `sign` for `x-flightbyte-signature` webhook signatures, and `FlightByte::Webhook::RackApp`, a Rack receiver that needs no Rack gem.
|
|
8
|
+
- An empty webhook signing secret is refused with an `ArgumentError`, by `Webhook::RackApp` as soon as it is built, since anyone can sign with an empty key. `Webhook::RackApp` reports what your block raises with `warn`, or to its `on_error:` option.
|
|
9
|
+
- `client.last_rate_limit` from the `X-RateLimit-*` headers, frozen.
|
|
10
|
+
- A redirect is followed only for a `GET` or `HEAD`, and only on the same scheme, host and port, so the API key stays with the API and a write is never replayed.
|
|
11
|
+
- RBS type signatures in `sig/`.
|
|
12
|
+
- No dependencies outside the standard library. Runs on Ruby 3.1 and later.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Batuhan ERKAN
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://flightbyte.dev">
|
|
3
|
+
<picture>
|
|
4
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
|
|
5
|
+
<img src="assets/logo-light.svg" alt="FlightByte" width="340">
|
|
6
|
+
</picture>
|
|
7
|
+
</a>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<h3 align="center">FlightByte for Ruby</h3>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
The official Ruby client for the FlightByte flight status API.<br>
|
|
14
|
+
Look flights up, watch them with signed webhooks and verify every delivery,<br>
|
|
15
|
+
from a Rails app, a background job or a script.
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<a href="https://rubygems.org/gems/flightbyte"><img src="https://img.shields.io/gem/v/flightbyte?style=flat-square&color=EF6A1A&label=gem" alt="gem version"></a>
|
|
20
|
+
<img src="https://img.shields.io/badge/Ruby-3.1%2B-121212?style=flat-square&logo=ruby&logoColor=white" alt="Ruby 3.1+">
|
|
21
|
+
<img src="https://img.shields.io/badge/dependencies-none-EF6A1A?style=flat-square" alt="No dependencies">
|
|
22
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-121212?style=flat-square" alt="MIT license"></a>
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
<p align="center">
|
|
26
|
+
<a href="https://flightbyte.dev/docs"><b>Docs</b></a> ·
|
|
27
|
+
<a href="https://app.flightbyte.dev/signup?utm_source=rubygems&utm_medium=readme&utm_campaign=ruby-sdk"><b>Get a free key</b></a> ·
|
|
28
|
+
<a href="https://flightbyte.dev/docs/reference"><b>API reference</b></a> ·
|
|
29
|
+
<a href="https://flightbyte.dev/docs/webhooks"><b>Webhook docs</b></a> ·
|
|
30
|
+
<a href="https://t.me/flightbyte"><b>Support</b></a>
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
1. Get a free API key at [app.flightbyte.dev/signup](https://app.flightbyte.dev/signup?utm_source=rubygems&utm_medium=readme&utm_campaign=ruby-sdk).
|
|
38
|
+
2. Add the gem:
|
|
39
|
+
|
|
40
|
+
| | Command |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| **Bundler** | `bundle add flightbyte` |
|
|
43
|
+
| **RubyGems** | `gem install flightbyte` |
|
|
44
|
+
|
|
45
|
+
3. Look a flight up:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
require "flightbyte"
|
|
49
|
+
|
|
50
|
+
client = FlightByte::Client.new(api_key: ENV.fetch("FLIGHTBYTE_API_KEY"))
|
|
51
|
+
|
|
52
|
+
flight = client.flights.get(
|
|
53
|
+
carrier: "TK",
|
|
54
|
+
flight_number: "1848",
|
|
55
|
+
date: "2026-09-08" # local departure date at the origin
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
puts flight.status.label # Scheduled
|
|
59
|
+
puts flight.departure.gate # A11
|
|
60
|
+
puts flight.departure.delay_minutes # 15
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Every field reads as a snake_case method, `flight["departure"]["delayMinutes"]` reads it by the API's own key, and `flight.to_h` is the JSON exactly as received. `date:` takes a `Date` too, and `FlightByte.format_date(time)` gives the `YYYY-MM-DD` the API expects, from a `Time` already in the origin airport's offset. To try it from a terminal, run the example in this repository:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git clone https://github.com/FlightByte-dev/flightbyte-ruby.git && cd flightbyte-ruby
|
|
67
|
+
FLIGHTBYTE_API_KEY=frk_live_... ruby -Ilib examples/lookup.rb TK 1 2026-11-15
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
TK1:2026-11-15@IST-JFK IST → JFK
|
|
72
|
+
Status Scheduled (scheduled)
|
|
73
|
+
Departure 2026-11-15T15:20:00.000 +03, gate D15
|
|
74
|
+
Arrival 2026-11-15T18:20:00.000 EST, baggage -
|
|
75
|
+
Quota 23 of 30 left this minute
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## What you can do
|
|
79
|
+
|
|
80
|
+
| You want to | You call |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| *Show a passenger their gate and delay* | `client.flights.get(...)` returns the live snapshot: phase, gate, terminal, baggage belt, scheduled, estimated and actual times, delay, aircraft and codeshares. |
|
|
83
|
+
| *Look up TK1 tomorrow, which flies two legs* | Pass `origin: "IST"`. Without it the snapshot lists every leg in `alternates`, and a subscription refuses to guess: the `BadRequestError` carries the `legs` to choose from. |
|
|
84
|
+
| *Be told when anything changes* | `client.subscriptions.create(...)` starts the watch and returns the signing secret, shown once. |
|
|
85
|
+
| *Find out why webhooks stopped arriving* | `client.subscriptions.get(id)` and `deliveries(id)` show the status and the HTTP code and error of every recent attempt. |
|
|
86
|
+
| *Trust a webhook on your server* | `FlightByte::Webhook::RackApp.new(secret) { \|event\| ... }` verifies the signature against the raw body, decodes the event and calls you. |
|
|
87
|
+
|
|
88
|
+
## API
|
|
89
|
+
|
|
90
|
+
**Flights**: read-only.
|
|
91
|
+
|
|
92
|
+
| Method | What it does |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| `flights.get` | Live status of one flight leg, and every leg the number flies that day.<br><sub>`GET /v1/flights/{carrier}/{flightNumber}/{date}`</sub> |
|
|
95
|
+
| `flights.timeline` | The airline's own change history for a leg, newest first.<br><sub>`GET /v1/flights/{carrier}/{flightNumber}/{date}/timeline`</sub> |
|
|
96
|
+
|
|
97
|
+
**Subscriptions**
|
|
98
|
+
|
|
99
|
+
| Method | What it does | Access |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `subscriptions.create` | Watch a flight and get a signed webhook for every change. Counts as a watched flight on your plan; pass `idempotency_key:` so a retry never counts twice.<br><sub>`POST /v1/subscriptions`</sub> | writes |
|
|
102
|
+
| `subscriptions.list` | One page of your subscriptions, filtered by status, carrier, number or date.<br><sub>`GET /v1/subscriptions`</sub> | read-only |
|
|
103
|
+
| `subscriptions.list_all` | Every matching subscription, fetched page by page as you iterate; without a block it is a lazy `Enumerator`.<br><sub>`GET /v1/subscriptions`</sub> | read-only |
|
|
104
|
+
| `subscriptions.get` | One subscription with its status and delivery counters.<br><sub>`GET /v1/subscriptions/{id}`</sub> | read-only |
|
|
105
|
+
| `subscriptions.update` | Change the webhook URL, event filter, delay threshold, headers or metadata.<br><sub>`PATCH /v1/subscriptions/{id}`</sub> | writes |
|
|
106
|
+
| `subscriptions.pause` | Stop polling and deliveries without losing anything.<br><sub>`POST /v1/subscriptions/{id}/pause`</sub> | writes |
|
|
107
|
+
| `subscriptions.resume` | Resume a paused or auto-suspended subscription.<br><sub>`POST /v1/subscriptions/{id}/resume`</sub> | writes |
|
|
108
|
+
| `subscriptions.delete` | Delete a subscription for good.<br><sub>`DELETE /v1/subscriptions/{id}`</sub> | **destructive** |
|
|
109
|
+
| `subscriptions.send_test` | Send a signed `subscription.test` webhook to the subscription's URL.<br><sub>`POST /v1/subscriptions/{id}/test`</sub> | writes |
|
|
110
|
+
| `subscriptions.deliveries` | Recent delivery attempts with HTTP status and errors.<br><sub>`GET /v1/subscriptions/{id}/deliveries`</sub> | read-only |
|
|
111
|
+
| `subscriptions.events` | Recent events with the exact payloads that were sent.<br><sub>`GET /v1/subscriptions/{id}/events`</sub> | read-only |
|
|
112
|
+
| `subscriptions.secret` | Reveal the signing secret again.<br><sub>`GET /v1/subscriptions/{id}/secret`</sub> | read-only |
|
|
113
|
+
| `subscriptions.rotate_secret` | Generate a new signing secret. Previous signatures stop validating.<br><sub>`POST /v1/subscriptions/{id}/secret/rotate`</sub> | writes |
|
|
114
|
+
|
|
115
|
+
**Countries**: read-only.
|
|
116
|
+
|
|
117
|
+
| Method | What it does |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| `countries.list` | Every country with its name in any language, emoji and flag URLs.<br><sub>`GET /v1/countries`</sub> |
|
|
120
|
+
| `countries.get` | One country by its ISO 3166-1 alpha-2 code.<br><sub>`GET /v1/countries/{code}`</sub> |
|
|
121
|
+
| `countries.flag_url` | The URL of a country's SVG flag, 4:3 or square. Needs no key, so it can go straight into an `<img>`.<br><sub>`GET /v1/flags/{code}.svg`</sub> |
|
|
122
|
+
|
|
123
|
+
Every method takes a `timeout:` of its own. Parameters are keywords named after the API's own, in snake_case (`carrier:`, `flight_number:`, `date:`, `origin:`, `webhook_url:`, …), and responses come back exactly as the API sent them, as frozen objects: timestamps are ISO 8601 strings, phases and statuses are strings, and no field is renamed or dropped. Each shape has its class, such as `FlightByte::FlightSnapshot` or `FlightByte::Subscription`, and they match patterns too: `in { status: { phase: "landed" } }`. The full snapshot is documented under [Flights](https://flightbyte.dev/docs/flights).
|
|
124
|
+
|
|
125
|
+
## Watch a flight
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
created = client.subscriptions.create(
|
|
129
|
+
carrier: "TK",
|
|
130
|
+
flight_number: "1848",
|
|
131
|
+
date: "2026-09-08",
|
|
132
|
+
webhook_url: "https://api.example.com/hooks/flights",
|
|
133
|
+
events: %w[departure.gate.changed delay.increased flight.departed],
|
|
134
|
+
metadata: { bookingId: "BK-1042" },
|
|
135
|
+
idempotency_key: "BK-1042" # safe to retry
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Shown in full only now: store it where your webhook receiver can read it.
|
|
139
|
+
signing_secret = created.secret
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
FlightByte only delivers to public HTTPS addresses. To receive on your machine, expose the port with a tunnel (`cloudflared tunnel --url http://localhost:3000` or `ngrok http 3000`) and use that URL. `subscriptions.send_test(id)` sends a signed `subscription.test` whenever you want to see the path work.
|
|
143
|
+
|
|
144
|
+
## Receive webhooks
|
|
145
|
+
|
|
146
|
+
A receiver has to do four things, in this order: verify the signature against the **raw** body, deduplicate on the event id, answer `2xx` before doing the work, and branch on the event type.
|
|
147
|
+
|
|
148
|
+
`FlightByte::Webhook::RackApp` does the first and answers for you: `401` for a bad signature, `400` for a body that is not an event, `500` when your block raises (so FlightByte retries) and `200` otherwise, plus `405` to anything but a `POST` and `413` to a body over 1 MiB. What your block raises goes to `warn`, or to the `on_error:` option. An empty secret raises an `ArgumentError` right away, because anyone can sign with an empty key: an unset environment variable fails at boot instead of accepting forged events. It is a plain Rack app that needs no Rack gem, so it mounts in every Ruby web framework:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
receiver = FlightByte::Webhook::RackApp.new(ENV.fetch("FLIGHTBYTE_WEBHOOK_SECRET")) do |event|
|
|
152
|
+
# A retry carries the same id: answer it, but don't handle it twice.
|
|
153
|
+
next unless mark_handled(event.id)
|
|
154
|
+
|
|
155
|
+
# Answer first: FlightByte treats a delivery as failed after 10 seconds.
|
|
156
|
+
FlightEventJob.perform_later(event.to_h)
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
<details>
|
|
161
|
+
<summary><b>Rails, Sinatra, Roda and a bare config.ru</b></summary>
|
|
162
|
+
|
|
163
|
+
<br>
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
# Build the receiver once, at boot: in Rails, an initializer that sets
|
|
167
|
+
# FLIGHTBYTE_RECEIVER = FlightByte::Webhook::RackApp.new(...) { |event| ... }
|
|
168
|
+
|
|
169
|
+
# Rails: config/routes.rb
|
|
170
|
+
mount FLIGHTBYTE_RECEIVER, at: "/hooks/flightbyte"
|
|
171
|
+
|
|
172
|
+
# Sinatra, Hanami or any app built with Rack::Builder: config.ru
|
|
173
|
+
map("/hooks/flightbyte") { run receiver }
|
|
174
|
+
run MyApp
|
|
175
|
+
|
|
176
|
+
# Roda
|
|
177
|
+
route do |r|
|
|
178
|
+
r.on("hooks", "flightbyte") { r.run receiver }
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
</details>
|
|
183
|
+
|
|
184
|
+
In a Rails controller, read the body with `request.raw_post`, not `params`: the signature covers the bytes as sent, and JSON parsed and serialised again no longer matches.
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
class FlightbyteWebhooksController < ActionController::API
|
|
188
|
+
def create
|
|
189
|
+
event = FlightByte::Webhook.construct_event(
|
|
190
|
+
request.raw_post,
|
|
191
|
+
request.headers[FlightByte::Webhook::SIGNATURE_HEADER],
|
|
192
|
+
ENV.fetch("FLIGHTBYTE_WEBHOOK_SECRET")
|
|
193
|
+
)
|
|
194
|
+
rescue FlightByte::WebhookSignatureError
|
|
195
|
+
head :unauthorized
|
|
196
|
+
rescue FlightByte::Error
|
|
197
|
+
head :bad_request
|
|
198
|
+
else
|
|
199
|
+
FlightEventJob.perform_later(event.to_h)
|
|
200
|
+
head :ok
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
class FlightEventJob < ApplicationJob
|
|
205
|
+
def perform(payload)
|
|
206
|
+
event = FlightByte::WebhookEvent.new(payload)
|
|
207
|
+
case event.type
|
|
208
|
+
when "flight.updated"
|
|
209
|
+
event.changes.each { |change| logger.info("#{event.flight_key} #{change.type}: #{change.message}") }
|
|
210
|
+
when "subscription.completed"
|
|
211
|
+
logger.info("#{event.flight_key} finished")
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
In a Sinatra route, the raw body is `request.body.read` and the header is `request.env[FlightByte::Webhook::SIGNATURE_ENV_KEY]`. A receiver with no framework, for any Rack server, is in [examples/webhook.ru](examples/webhook.ru).
|
|
218
|
+
|
|
219
|
+
<details>
|
|
220
|
+
<summary><b>How the signature works</b></summary>
|
|
221
|
+
|
|
222
|
+
<br>
|
|
223
|
+
|
|
224
|
+
```text
|
|
225
|
+
x-flightbyte-signature: t=1789305971,v1=5f1c9a...e3b2
|
|
226
|
+
v1 = HMAC-SHA256(secret, "<t>." + <raw body>)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`FlightByte::Webhook.verify` computes the HMAC over the body you received, not over JSON you decoded and encoded again, compares it in constant time with `OpenSSL.fixed_length_secure_compare`, accepts any of several `v1` values, and rejects a `t` more than five minutes from now. `Webhook.sign` produces the same header, for testing your receiver. The test suite checks both against signatures made by the API's own signer, and fuzzes the header parser.
|
|
230
|
+
|
|
231
|
+
The reasons behind each step are in [Verifying webhook signatures](https://flightbyte.dev/guides/verifying-webhook-signatures), and reference receivers in Node.js, Python and Go are in [flightbyte-webhook-examples](https://github.com/FlightByte-dev/flightbyte-webhook-examples).
|
|
232
|
+
|
|
233
|
+
</details>
|
|
234
|
+
|
|
235
|
+
## Errors
|
|
236
|
+
|
|
237
|
+
Every failed request raises a subclass of `FlightByte::Error`, so you `rescue` the ones you can handle and let the rest through.
|
|
238
|
+
|
|
239
|
+
| Error | Status | When, and what to do |
|
|
240
|
+
| --- | --- | --- |
|
|
241
|
+
| `BadRequestError` | `400` | Invalid input; `messages` has one line per field. For a number that flies twice, `legs` lists them: pass one's `origin`. |
|
|
242
|
+
| `AuthenticationError` | `401` | Missing, invalid, revoked or expired API key. |
|
|
243
|
+
| `PaymentRequiredError` | `402` | No watched flights left. `code` says why: `watched_flight_limit`, `overage_cap_reached` (see `resets_at`) or `credits_exhausted` (see `balance` and `expires_at`). |
|
|
244
|
+
| `PermissionDeniedError` | `403` | The account is suspended, or a token lacks the scope (`code`). |
|
|
245
|
+
| `NotFoundError` | `404` | The flight did not operate that day, or no such subscription. |
|
|
246
|
+
| `RateLimitError` | `429` | A minute, day or month quota is used up. Wait `retry_after` seconds. |
|
|
247
|
+
| `UpstreamError` | `502` | The flight data provider failed. Retry shortly. It is also a `ServerError`. |
|
|
248
|
+
| `ServerError`, `UnexpectedStatusError` | other | Anything else. `request_id` is what support asks for. |
|
|
249
|
+
| `ConnectionError` | none | No response at all: refused connection, DNS, TLS or a dropped socket, or `timed_out?` when the timeout ran out. The original exception is its `cause`. |
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
begin
|
|
253
|
+
client.subscriptions.create(**params)
|
|
254
|
+
rescue FlightByte::BadRequestError => e
|
|
255
|
+
raise if e.legs.empty?
|
|
256
|
+
|
|
257
|
+
puts "Pick a leg: #{e.legs.map(&:origin).join(", ")}"
|
|
258
|
+
rescue FlightByte::PaymentRequiredError => e
|
|
259
|
+
puts "No watched flights left (#{e.code}) until #{e.resets_at}"
|
|
260
|
+
rescue FlightByte::RateLimitError => e
|
|
261
|
+
sleep(e.retry_after || 5)
|
|
262
|
+
retry
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Arguments that cannot make a valid request, such as an empty id or a blank API key, raise an `ArgumentError` before anything is sent. Every status is explained in the [errors docs](https://flightbyte.dev/docs/errors).
|
|
267
|
+
|
|
268
|
+
## Built for Ruby apps
|
|
269
|
+
|
|
270
|
+
- **One client, many threads.** A `FlightByte::Client` is safe to share between Puma threads and Sidekiq workers; create it once, in an initializer for example. Responses are frozen, so they can be cached and handed between threads. CI runs every Ruby from 3.1 to 4.0.
|
|
271
|
+
- **No dependencies.** Only the standard library: `net/http`, `openssl` and `json`. Nothing to audit but this gem.
|
|
272
|
+
- **Your key stays on your server.** An API key or a signing secret shipped inside a browser or mobile app can be pulled out of it by anyone who has the app. Flag images are the exception: `countries.flag_url` needs no key. On the server, `inspect` never shows the key and shows a signing secret masked, so neither lands in your logs.
|
|
273
|
+
- **Your key stays with the API.** The client follows a redirect only for a read, and only on the API's own origin (scheme, host and port), so a misdirected answer cannot carry the key elsewhere and a write is never sent twice.
|
|
274
|
+
- **New API values don't break old code.** A phase, status or change type added after this release is still a plain string, and a field added after it reads as a method like the others. The tests check every method, parameter and model against the API's OpenAPI document, so a field the API adds shows up as a failing test here first, not as a missing method in your code.
|
|
275
|
+
- **Zero stays apart from unknown.** A field the API left out or sent as `null` reads as `nil`, so a delay of `0` and no delay reading are different things, and `key?` tells the two apart. A misspelt field raises `NoMethodError` instead of passing for a missing value.
|
|
276
|
+
- **Errors say what to do next.** A number that flies twice comes back with its legs, a `429` with how long to wait, a `402` with the reason and when the allowance resets.
|
|
277
|
+
- **Quotas are visible.** `client.last_rate_limit` holds the minute, day and month windows from the latest response's `X-RateLimit-*` headers.
|
|
278
|
+
|
|
279
|
+
## Configuration
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
client = FlightByte::Client.new(
|
|
283
|
+
api_key: ENV.fetch("FLIGHTBYTE_API_KEY"),
|
|
284
|
+
base_url: "http://localhost:3000/v1",
|
|
285
|
+
timeout: 10,
|
|
286
|
+
user_agent: "my-app/1.2"
|
|
287
|
+
)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
| Option | Default | Meaning |
|
|
291
|
+
| --- | --- | --- |
|
|
292
|
+
| `api_key` | required | Your API key, sent as `x-api-key`. |
|
|
293
|
+
| `base_url` | `https://api.flightbyte.dev/v1` | API base URL, e.g. `http://localhost:3000/v1` for a local API. |
|
|
294
|
+
| `timeout` | `30` | Seconds each request may take, connecting, redirects and response body included. `0` disables it; a call's own `timeout:` overrides it. |
|
|
295
|
+
| `user_agent` | none | Put in front of `flightbyte-ruby/<version>` in the `user-agent` header. |
|
|
296
|
+
|
|
297
|
+
Requests go through `Net::HTTP`, so the usual `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` environment variables apply.
|
|
298
|
+
|
|
299
|
+
## Development
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
bundle install
|
|
303
|
+
bundle exec rake # RuboCop, the signature check and the tests
|
|
304
|
+
bundle exec rake sync_openapi # refresh spec/openapi.json, then run the tests against it
|
|
305
|
+
node tool/webhook_vectors.mjs
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
The tests never touch the real API and need no key. They cover every method's request mapping, the error class for each status, redirects and timeouts against real local servers, the models against fixtures shaped like the API's JSON and against the API's OpenAPI document, the Rack receiver with `Rack::Lint`, and webhook verification against signatures made with Node's `createHmac` exactly as the API signs them. CI runs them on Ruby 3.1, 3.2, 3.3, 3.4 and 4.0, and builds and installs the gem.
|
|
309
|
+
|
|
310
|
+
How to add a field, an enum value or an endpoint is in [CONTRIBUTING.md](CONTRIBUTING.md). Report security issues by email, as [SECURITY.md](SECURITY.md) explains, never in a public issue.
|
|
311
|
+
|
|
312
|
+
<details>
|
|
313
|
+
<summary><b>Project layout</b></summary>
|
|
314
|
+
|
|
315
|
+
```text
|
|
316
|
+
lib/
|
|
317
|
+
flightbyte.rb entry point, format_date and the helpers
|
|
318
|
+
flightbyte/client.rb FlightByte::Client and its options
|
|
319
|
+
flightbyte/transport.rb requests, headers, timeouts, redirects, error mapping
|
|
320
|
+
flightbyte/resources/ flights, subscriptions, countries
|
|
321
|
+
flightbyte/object.rb FlightByte::Object, the read-only base of every response
|
|
322
|
+
flightbyte/models.rb a class for each of the API's shapes
|
|
323
|
+
flightbyte/values.rb the API's value lists
|
|
324
|
+
flightbyte/errors.rb FlightByte::Error and its subclasses
|
|
325
|
+
flightbyte/webhook.rb Webhook.verify, construct_event and sign
|
|
326
|
+
flightbyte/webhook/rack_app.rb Webhook::RackApp, the Rack receiver
|
|
327
|
+
flightbyte/rate_limit.rb X-RateLimit-* headers
|
|
328
|
+
sig/flightbyte.rbs RBS type signatures
|
|
329
|
+
spec/openapi.json the API document the tests check against
|
|
330
|
+
test/fixtures/ API-shaped JSON and signed webhook vectors
|
|
331
|
+
examples/ a lookup from the terminal and a Rack receiver
|
|
332
|
+
tool/sync_openapi.rb refreshes spec/openapi.json
|
|
333
|
+
tool/webhook_vectors.mjs regenerates the signed vectors with Node, as the API signs
|
|
334
|
+
tool/release_notes.sh a version's CHANGELOG.md section, for the GitHub release
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
</details>
|
|
338
|
+
|
|
339
|
+
<details>
|
|
340
|
+
<summary><b>Releasing</b></summary>
|
|
341
|
+
|
|
342
|
+
1. Set `VERSION` in `lib/flightbyte/version.rb`; the gemspec reads it.
|
|
343
|
+
2. Give the version its own `## <version>` section in `CHANGELOG.md`; a test fails without it.
|
|
344
|
+
3. Push a tag such as `v1.0.1`.
|
|
345
|
+
|
|
346
|
+
The release workflow checks that the tag, `FlightByte::VERSION` and the gemspec agree and that the changelog has the section, runs every check, publishes to RubyGems with trusted publishing unless that version is already there, and creates the GitHub release from the changelog section. Run it by hand from the Actions tab to rehearse: it builds the gem and publishes nothing.
|
|
347
|
+
|
|
348
|
+
</details>
|
|
349
|
+
|
|
350
|
+
## License
|
|
351
|
+
|
|
352
|
+
[MIT](LICENSE)
|
|
353
|
+
|
|
354
|
+
<br>
|
|
355
|
+
|
|
356
|
+
<p align="center">
|
|
357
|
+
<a href="https://flightbyte.dev"><img src="assets/icon.svg" alt="FlightByte" width="36"></a>
|
|
358
|
+
<br>
|
|
359
|
+
<sub><b>FlightByte</b> · Flight intelligence, byte by byte · <a href="https://flightbyte.dev">flightbyte.dev</a></sub>
|
|
360
|
+
</p>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module FlightByte
|
|
6
|
+
# A client for the FlightByte API. Create one and share it: it is safe to use
|
|
7
|
+
# from many threads at once.
|
|
8
|
+
#
|
|
9
|
+
# client = FlightByte::Client.new(api_key: ENV.fetch("FLIGHTBYTE_API_KEY"))
|
|
10
|
+
# flight = client.flights.get(carrier: "TK", flight_number: "1848", date: "2026-09-08")
|
|
11
|
+
# puts flight.status.label
|
|
12
|
+
#
|
|
13
|
+
# Keep API keys on a server you control. A key shipped inside a browser or
|
|
14
|
+
# mobile app can be extracted by anyone who has the app.
|
|
15
|
+
class Client
|
|
16
|
+
# The production API root.
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.flightbyte.dev/v1"
|
|
18
|
+
|
|
19
|
+
# Seconds each request may take, response body included, unless told otherwise.
|
|
20
|
+
DEFAULT_TIMEOUT = 30
|
|
21
|
+
|
|
22
|
+
# @return [Flights] flight lookups
|
|
23
|
+
attr_reader :flights
|
|
24
|
+
# @return [Subscriptions] watched flights and their webhooks
|
|
25
|
+
attr_reader :subscriptions
|
|
26
|
+
# @return [Countries] countries and flags
|
|
27
|
+
attr_reader :countries
|
|
28
|
+
# @return [String] the API root this client talks to
|
|
29
|
+
attr_reader :base_url
|
|
30
|
+
# @return [Numeric] seconds each request may take; `0` means no limit
|
|
31
|
+
attr_reader :timeout
|
|
32
|
+
# @return [String] the `User-Agent` header sent with every request
|
|
33
|
+
attr_reader :user_agent
|
|
34
|
+
|
|
35
|
+
# @param api_key [String] your API key, sent as `x-api-key`. Keep it on a server you control.
|
|
36
|
+
# @param base_url [String] the API root, for example `http://localhost:3000/v1` for a local API
|
|
37
|
+
# @param timeout [Numeric] seconds each request may take, connecting,
|
|
38
|
+
# redirects and the response body included; `0` disables the limit
|
|
39
|
+
# @param user_agent [String, nil] put in front of this gem's own user
|
|
40
|
+
# agent, for example `my-app/1.2`
|
|
41
|
+
# @raise [ArgumentError] when the key is empty, the base URL is not an
|
|
42
|
+
# http or https URL, or the timeout is negative
|
|
43
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, user_agent: nil)
|
|
44
|
+
api_key = api_key.is_a?(String) ? api_key.strip : ""
|
|
45
|
+
raise ArgumentError, "flightbyte: api_key is required." if api_key.empty?
|
|
46
|
+
|
|
47
|
+
@base_url = Client.check_base_url(base_url)
|
|
48
|
+
@timeout = Transport.check_timeout(timeout)
|
|
49
|
+
own = "flightbyte-ruby/#{VERSION}"
|
|
50
|
+
prefix = user_agent.to_s.strip
|
|
51
|
+
@user_agent = (prefix.empty? ? own : "#{prefix} #{own}").freeze
|
|
52
|
+
|
|
53
|
+
@transport = Transport.new(api_key: api_key.freeze, base_url: @base_url, timeout: @timeout,
|
|
54
|
+
user_agent: @user_agent)
|
|
55
|
+
@flights = Flights.new(@transport)
|
|
56
|
+
@subscriptions = Subscriptions.new(@transport)
|
|
57
|
+
@countries = Countries.new(@transport)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The quota state from the latest response that reported one, or `nil`
|
|
61
|
+
# before any did. It is frozen, so reading it never changes the client.
|
|
62
|
+
#
|
|
63
|
+
# @return [RateLimit, nil]
|
|
64
|
+
def last_rate_limit
|
|
65
|
+
@transport.last_rate_limit
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Never shows the API key.
|
|
69
|
+
def inspect
|
|
70
|
+
"#<#{self.class.name} base_url=#{@base_url.inspect}>"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @api private
|
|
74
|
+
def self.check_base_url(value)
|
|
75
|
+
uri = begin
|
|
76
|
+
URI.parse(value.to_s)
|
|
77
|
+
rescue URI::Error
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
raise ArgumentError, "flightbyte: base_url #{value.inspect} is not a URL." unless uri
|
|
81
|
+
|
|
82
|
+
unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
83
|
+
raise ArgumentError, "flightbyte: base_url #{value.inspect} must be an http or https URL."
|
|
84
|
+
end
|
|
85
|
+
if uri.query || uri.fragment
|
|
86
|
+
raise ArgumentError, "flightbyte: base_url #{value.inspect} must not have a query or fragment."
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
uri.to_s.sub(%r{/+\z}, "").freeze
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|