inbio 0.1.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/LICENSE +21 -0
- data/README.md +287 -0
- data/lib/inbio/client.rb +142 -0
- data/lib/inbio/errors.rb +128 -0
- data/lib/inbio/models.rb +101 -0
- data/lib/inbio/page.rb +39 -0
- data/lib/inbio/resources/account.rb +16 -0
- data/lib/inbio/resources/folders.rb +16 -0
- data/lib/inbio/resources/links.rb +102 -0
- data/lib/inbio/resources/tags.rb +16 -0
- data/lib/inbio/transport.rb +48 -0
- data/lib/inbio/version.rb +5 -0
- data/lib/inbio/webhooks.rb +92 -0
- data/lib/inbio.rb +29 -0
- metadata +61 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e87a4134eb1ec3417a54fb204fab8c3dad50f156fab628f4b7762f47c2a47b68
|
|
4
|
+
data.tar.gz: 7f99e07619f0430bd9dbdf8c59ee3d40dd72da3d868ae8da42b772fa99f43ac9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7b9e3a91fd7aacf8d31733ee5d9599860d1842b5c431c9c1a66ca494e267436230e71b087a4a977e436cdf9fa8b6551cd39afb00ea537243d2247bbd810b73aa
|
|
7
|
+
data.tar.gz: dc20ed1d150f129f4927dccf90c7a78c1a14090d9ae96253fd79a246b525676800f8a06200bfd2f18296ad75e5abf77d6fbd9eb2e9d04e816b360027c0d4d735
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 INBIO
|
|
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,287 @@
|
|
|
1
|
+
# inbio — official Ruby SDK for INBIO (in.bio)
|
|
2
|
+
|
|
3
|
+
The official Ruby SDK for [INBIO](https://in.bio), the premium URL shortener
|
|
4
|
+
with click analytics and customizable QR codes. Shorten links, generate
|
|
5
|
+
styled QR codes, read analytics, and manage folders and tags from Ruby.
|
|
6
|
+
|
|
7
|
+
- **Free to start** — `Inbio.shorten` and the [QR API](https://docs.in.bio/api/free-qr) need **no account and no API key**
|
|
8
|
+
- **Zero runtime dependencies** — stdlib `net/http` and `openssl` only, Ruby ≥ 3.0
|
|
9
|
+
- **Complete** — covers the entire [INBIO REST API](https://docs.in.bio/api): links CRUD, lazy enumerator pagination, bulk create, QR codes, analytics, webhook signature verification
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
gem install inbio
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or in your Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "inbio"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Shorten a URL — no account needed
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "inbio"
|
|
27
|
+
|
|
28
|
+
puts Inbio.shorten("https://example.com/very/long/url").short_url
|
|
29
|
+
# => "https://in.bio/abc123"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The free endpoint is keyless and rate-limited (5/min per IP). The result also
|
|
33
|
+
carries `slug`, `qr_url`, `preview_url`, `claim_url` (turn the anonymous link into
|
|
34
|
+
a permanent one on your account) and `expires` — anonymous links are deleted after
|
|
35
|
+
30 days unless claimed.
|
|
36
|
+
|
|
37
|
+
## Authenticated quickstart
|
|
38
|
+
|
|
39
|
+
Create a token under **Settings → API tokens** (API access requires Pro or Business).
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
require "inbio"
|
|
43
|
+
|
|
44
|
+
client = Inbio::Client.new(token: "YOUR_API_TOKEN")
|
|
45
|
+
|
|
46
|
+
link = client.links.create("https://example.com/sale", slug: "spring-sale")
|
|
47
|
+
link.short_url # => "https://in.bio/spring-sale"
|
|
48
|
+
link.total_clicks # => 0
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
With no `token:` argument the client reads `ENV["INBIO_API_TOKEN"]`:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
client = Inbio::Client.new # uses INBIO_API_TOKEN
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Links
|
|
58
|
+
|
|
59
|
+
### List and iterate
|
|
60
|
+
|
|
61
|
+
`list` returns one page; `iterate` returns a lazy `Enumerator` that paginates
|
|
62
|
+
through everything for you:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
page = client.links.list(status: "active", per_page: 50)
|
|
66
|
+
page.total # => 57
|
|
67
|
+
page.current_page # => 1
|
|
68
|
+
page.next? # => true
|
|
69
|
+
page.each { |link| puts link.short_url }
|
|
70
|
+
|
|
71
|
+
# Every link, across all pages, fetched on demand:
|
|
72
|
+
client.links.iterate(tag: "marketing").each do |link|
|
|
73
|
+
puts "#{link.slug}: #{link.total_clicks} clicks"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
client.links.iterate(search: "sale").first(10) # stops fetching after 10
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Filters: `search:`, `folder_id:`, `tag:`, `status:` (`active`, `disabled`,
|
|
80
|
+
`archived`, `expired`, `exhausted`, `blocked`, `pending_review`), `per_page:` (1–100).
|
|
81
|
+
`list_all` is an alias for `iterate`.
|
|
82
|
+
|
|
83
|
+
### Create with options
|
|
84
|
+
|
|
85
|
+
Only the destination URL is required:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
link = client.links.create(
|
|
89
|
+
"https://example.com/launch",
|
|
90
|
+
slug: "launch", # 4–64 chars [a-zA-Z0-9-_]; omitted => random
|
|
91
|
+
title: "Product launch",
|
|
92
|
+
description: "Fall campaign",
|
|
93
|
+
notes: "Internal note",
|
|
94
|
+
redirect_type: 301, # 301, 302 (default) or 307
|
|
95
|
+
folder_id: 3,
|
|
96
|
+
tags: ["marketing", "fall"], # created on the fly
|
|
97
|
+
expires_at: "2026-12-31T23:59:59Z", # Pro+
|
|
98
|
+
fallback_url: "https://example.com/expired", # Pro+
|
|
99
|
+
click_limit: 10_000, # Pro+
|
|
100
|
+
password: "s3cret", # Pro+
|
|
101
|
+
utm: { source: "newsletter", medium: "email", campaign: "fall" }
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Read, update, enable/disable, delete
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
link = client.links.get(42)
|
|
109
|
+
|
|
110
|
+
link = client.links.update(42, title: "New title", tags: ["archive"])
|
|
111
|
+
# Editing destination_url requires the edit-destination feature (Pro+).
|
|
112
|
+
|
|
113
|
+
client.links.disable(42) # active -> disabled
|
|
114
|
+
client.links.enable(42) # disabled -> active
|
|
115
|
+
# Any other transition raises Inbio::StateConflictError (error.current has the status).
|
|
116
|
+
|
|
117
|
+
client.links.delete(42) # => nil (204); the link stops redirecting
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Bulk create (Business)
|
|
121
|
+
|
|
122
|
+
Up to 100 links per call; rows fail independently:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
result = client.links.bulk_create([
|
|
126
|
+
{ destination_url: "https://example.com/a", slug: "promo-a" },
|
|
127
|
+
{ destination_url: "https://example.com/b", slug: "promo-b" }
|
|
128
|
+
])
|
|
129
|
+
result.created.each { |link| puts link.short_url }
|
|
130
|
+
result.failed.each { |row| warn "row #{row['index']}: #{row['error']}" }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### QR code to file
|
|
134
|
+
|
|
135
|
+
`qr` returns raw image bytes — write them straight to disk:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
File.binwrite("spring-sale.png", client.links.qr(42))
|
|
139
|
+
File.binwrite("spring-sale.svg", client.links.qr(42, format: "svg", size: 1024))
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The QR encodes the short URL, so editing the destination never invalidates
|
|
143
|
+
printed codes.
|
|
144
|
+
|
|
145
|
+
### Analytics
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
stats = client.links.analytics(42, from: "2026-06-01", to: "2026-06-30")
|
|
149
|
+
|
|
150
|
+
stats.totals # => {"clicks"=>1240, "uniques"=>981, "botClicks"=>77}
|
|
151
|
+
stats.series # => [{"date"=>"2026-06-01", "clicks"=>40, "uniques"=>31}, ...]
|
|
152
|
+
stats.countries # top 10: [{"value"=>"US", "clicks"=>512}, ...]
|
|
153
|
+
stats.devices, stats.browsers, stats.referrers # same shape
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Bot traffic is excluded everywhere except `totals["botClicks"]`.
|
|
157
|
+
|
|
158
|
+
## Folders and tags
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
client.folders.list.each { |f| puts "#{f.name} (#{f.links_count} links)" }
|
|
162
|
+
client.tags.list.each { |t| puts "#{t.name} (#{t.links_count} links)" }
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Account usage
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
usage = client.account.usage
|
|
169
|
+
usage.plan # => "pro"
|
|
170
|
+
usage.usage["links_created"] # => 120
|
|
171
|
+
usage.limits["links_per_month"] # => 2000
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Webhook verification
|
|
175
|
+
|
|
176
|
+
in.bio signs every delivery with `X-Inbio-Signature: t=<unix>,v1=<hex>` where
|
|
177
|
+
`v1 = HMAC-SHA256(secret, "<t>.<raw body>")`. Verify with the exact raw request
|
|
178
|
+
body (before parsing) — the comparison is constant-time and deliveries older
|
|
179
|
+
than `tolerance` (default 300s) are rejected to prevent replays.
|
|
180
|
+
|
|
181
|
+
Rails:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
class WebhooksController < ApplicationController
|
|
185
|
+
skip_before_action :verify_authenticity_token
|
|
186
|
+
|
|
187
|
+
def inbio
|
|
188
|
+
event = Inbio::Webhooks.verify(
|
|
189
|
+
request.raw_post,
|
|
190
|
+
request.headers["X-Inbio-Signature"],
|
|
191
|
+
ENV.fetch("INBIO_WEBHOOK_SECRET")
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
case event.event
|
|
195
|
+
when "link.clicked" then Metrics.count(event.data["slug"])
|
|
196
|
+
when "link.click_limit_reached" then AlertMailer.limit(event.data).deliver_later
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
head :ok
|
|
200
|
+
rescue Inbio::SignatureVerificationError
|
|
201
|
+
head :bad_request
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Rack/Sinatra:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
post "/webhooks/inbio" do
|
|
210
|
+
event = Inbio::Webhooks.verify(
|
|
211
|
+
request.body.read,
|
|
212
|
+
request.env["HTTP_X_INBIO_SIGNATURE"],
|
|
213
|
+
ENV.fetch("INBIO_WEBHOOK_SECRET")
|
|
214
|
+
)
|
|
215
|
+
# handle event.event / event.data ...
|
|
216
|
+
200
|
|
217
|
+
rescue Inbio::SignatureVerificationError
|
|
218
|
+
400
|
|
219
|
+
end
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`Inbio::Webhooks.construct_event` is an alias; `client.webhooks.verify(...)`
|
|
223
|
+
works too. No HTTP is involved — it is a pure crypto helper.
|
|
224
|
+
|
|
225
|
+
## Error handling
|
|
226
|
+
|
|
227
|
+
Every error is a subclass of `Inbio::Error`, which exposes `message`, `status`,
|
|
228
|
+
`error_type` and the parsed response `body`.
|
|
229
|
+
|
|
230
|
+
| HTTP | Error | Extras |
|
|
231
|
+
|---|---|---|
|
|
232
|
+
| 401 | `Inbio::AuthenticationError` | |
|
|
233
|
+
| 403 | `Inbio::AccessError` | `error_type` (`"plan"`, `"scope"`, `"account"`), `required_scope` |
|
|
234
|
+
| 404 | `Inbio::NotFoundError` | also returned for links you don't own |
|
|
235
|
+
| 409 | `Inbio::StateConflictError` | `current` — the link's current status |
|
|
236
|
+
| 422 | `Inbio::ValidationError` | `errors` — field name → array of messages |
|
|
237
|
+
| 422 (`error.type=entitlement`) | `Inbio::EntitlementError` | plan feature/limit hit |
|
|
238
|
+
| 429 | `Inbio::RateLimitError` | `retry_after` (seconds) |
|
|
239
|
+
| 5xx | `Inbio::ServerError` | |
|
|
240
|
+
| — | `Inbio::SignatureVerificationError` | webhook verification only |
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
begin
|
|
244
|
+
client.links.create("https://example.com", slug: "x")
|
|
245
|
+
rescue Inbio::ValidationError => e
|
|
246
|
+
e.errors # => {"slug"=>["The slug must be at least 3 characters."]}
|
|
247
|
+
rescue Inbio::AccessError => e
|
|
248
|
+
e.required_scope # => "links:write" when a token scope is missing
|
|
249
|
+
rescue Inbio::RateLimitError => e
|
|
250
|
+
sleep e.retry_after
|
|
251
|
+
retry
|
|
252
|
+
end
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Retries and timeouts
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
client = Inbio::Client.new(
|
|
259
|
+
token: "...",
|
|
260
|
+
base_url: "https://in.bio", # default
|
|
261
|
+
timeout: 30, # seconds, default 30
|
|
262
|
+
max_retries: 2 # default 2
|
|
263
|
+
)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Retries apply only to idempotent GET requests (on connection errors and 5xx) and
|
|
267
|
+
to 429 responses that carry `Retry-After` — with exponential backoff capped at
|
|
268
|
+
10 seconds. Everything else fails fast.
|
|
269
|
+
|
|
270
|
+
## About INBIO
|
|
271
|
+
|
|
272
|
+
[INBIO](https://in.bio) (`in.bio`) is a URL shortener and link-management
|
|
273
|
+
platform: short links with custom slugs, real-time click analytics
|
|
274
|
+
(countries, devices, browsers, referrers — bots filtered out), a QR code
|
|
275
|
+
studio with dot styles, marker shapes and colors, folders, tags, UTM
|
|
276
|
+
tools, and a REST API with webhooks. Free plan included.
|
|
277
|
+
|
|
278
|
+
- Website: https://in.bio
|
|
279
|
+
- Documentation: https://docs.in.bio
|
|
280
|
+
- Free shorten API (no key): https://docs.in.bio/api/free-shorten
|
|
281
|
+
- Free QR code API (no key): https://docs.in.bio/api/free-qr
|
|
282
|
+
- MCP server for AI agents: https://docs.in.bio/api/mcp
|
|
283
|
+
- All SDKs (JavaScript, Python, PHP, Ruby, Go): https://docs.in.bio/sdks
|
|
284
|
+
|
|
285
|
+
## License
|
|
286
|
+
|
|
287
|
+
MIT © [InBio, Inc.](https://in.bio)
|
data/lib/inbio/client.rb
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "openssl"
|
|
6
|
+
|
|
7
|
+
module Inbio
|
|
8
|
+
# HTTP client for the in.bio API.
|
|
9
|
+
#
|
|
10
|
+
# client = Inbio::Client.new(token: "...") # or ENV["INBIO_API_TOKEN"]
|
|
11
|
+
# client.links.create("https://example.com")
|
|
12
|
+
class Client
|
|
13
|
+
DEFAULT_BASE_URL = "https://in.bio"
|
|
14
|
+
DEFAULT_TIMEOUT = 30
|
|
15
|
+
DEFAULT_MAX_RETRIES = 2
|
|
16
|
+
MAX_BACKOFF = 10
|
|
17
|
+
|
|
18
|
+
attr_reader :base_url, :timeout, :max_retries,
|
|
19
|
+
:links, :folders, :tags, :account, :webhooks
|
|
20
|
+
|
|
21
|
+
# token:: API bearer token; falls back to ENV["INBIO_API_TOKEN"].
|
|
22
|
+
# Only the free +shorten+ call works without one.
|
|
23
|
+
# base_url:: API origin, default "https://in.bio".
|
|
24
|
+
# timeout:: per-request timeout in seconds, default 30.
|
|
25
|
+
# max_retries:: default 2 — retries only idempotent GETs and 429s that
|
|
26
|
+
# carry Retry-After, with exponential backoff capped at 10s.
|
|
27
|
+
# transport:: swap the HTTP layer (any object responding to +call+ like
|
|
28
|
+
# Inbio::Transport); used by the offline tests.
|
|
29
|
+
def initialize(token: nil, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
|
|
30
|
+
max_retries: DEFAULT_MAX_RETRIES, transport: nil)
|
|
31
|
+
@token = token || ENV["INBIO_API_TOKEN"]
|
|
32
|
+
@base_url = base_url.to_s.chomp("/")
|
|
33
|
+
@timeout = timeout
|
|
34
|
+
@max_retries = max_retries
|
|
35
|
+
@transport = transport || Transport.new
|
|
36
|
+
|
|
37
|
+
@links = Resources::Links.new(self)
|
|
38
|
+
@folders = Resources::Folders.new(self)
|
|
39
|
+
@tags = Resources::Tags.new(self)
|
|
40
|
+
@account = Resources::Account.new(self)
|
|
41
|
+
@webhooks = Webhooks.new
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Free keyless endpoint: POST /api/shorten. Works without a token.
|
|
45
|
+
def shorten(url)
|
|
46
|
+
ShortenResult.new(request(:post, "/api/shorten", body: { url: url }))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Single choke point for every HTTP request the SDK makes.
|
|
50
|
+
# Returns parsed JSON (or nil for empty/204 responses), or the raw body
|
|
51
|
+
# string when raw: true. Raises an Inbio::Error subclass on failure.
|
|
52
|
+
def request(method, path, query: nil, body: nil, raw: false)
|
|
53
|
+
url = build_url(path, query)
|
|
54
|
+
headers = default_headers
|
|
55
|
+
payload = nil
|
|
56
|
+
if body
|
|
57
|
+
payload = JSON.generate(body)
|
|
58
|
+
headers["Content-Type"] = "application/json"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
attempt = 0
|
|
62
|
+
loop do
|
|
63
|
+
begin
|
|
64
|
+
response = @transport.call(method, url, headers: headers, body: payload, timeout: @timeout)
|
|
65
|
+
rescue Timeout::Error, IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError => e
|
|
66
|
+
if method == :get && attempt < @max_retries
|
|
67
|
+
attempt += 1
|
|
68
|
+
backoff_sleep(backoff_delay(attempt))
|
|
69
|
+
retry
|
|
70
|
+
end
|
|
71
|
+
raise Error.new("Connection error: #{e.class}: #{e.message}")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
status = response.status
|
|
75
|
+
if (200..299).cover?(status)
|
|
76
|
+
return response.body if raw
|
|
77
|
+
|
|
78
|
+
return parse_json(response.body)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
response_headers = normalize_headers(response.headers)
|
|
82
|
+
retry_after = response_headers["retry-after"]
|
|
83
|
+
|
|
84
|
+
if attempt < @max_retries && retryable?(method, status, retry_after)
|
|
85
|
+
attempt += 1
|
|
86
|
+
delay = backoff_delay(attempt)
|
|
87
|
+
delay = [delay, retry_after.to_i].max if status == 429 && retry_after
|
|
88
|
+
backoff_sleep([delay, MAX_BACKOFF].min)
|
|
89
|
+
next
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
raise Error.from_response(status, response.body, response_headers)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def build_url(path, query)
|
|
99
|
+
url = @base_url + path
|
|
100
|
+
url += "?#{URI.encode_www_form(query)}" if query && !query.empty?
|
|
101
|
+
url
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def default_headers
|
|
105
|
+
headers = {
|
|
106
|
+
"Accept" => "application/json",
|
|
107
|
+
"User-Agent" => "inbio-ruby/#{VERSION}"
|
|
108
|
+
}
|
|
109
|
+
headers["Authorization"] = "Bearer #{@token}" if @token
|
|
110
|
+
headers
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def parse_json(body)
|
|
114
|
+
return nil if body.nil? || body.empty?
|
|
115
|
+
|
|
116
|
+
JSON.parse(body)
|
|
117
|
+
rescue JSON::ParserError
|
|
118
|
+
raise Error.new("Invalid JSON in response body", body: body)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def normalize_headers(headers)
|
|
122
|
+
(headers || {}).each_with_object({}) { |(name, value), acc| acc[name.to_s.downcase] = value }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Only idempotent GETs (on 5xx) and rate limits that tell us when to
|
|
126
|
+
# come back (429 + Retry-After) are retried.
|
|
127
|
+
def retryable?(method, status, retry_after)
|
|
128
|
+
return true if method == :get && status >= 500
|
|
129
|
+
return true if status == 429 && retry_after
|
|
130
|
+
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def backoff_delay(attempt)
|
|
135
|
+
[2**(attempt - 1), MAX_BACKOFF].min
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def backoff_sleep(seconds)
|
|
139
|
+
sleep(seconds) if seconds.positive?
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
data/lib/inbio/errors.rb
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Inbio
|
|
6
|
+
# Base class for every error raised by this gem.
|
|
7
|
+
#
|
|
8
|
+
# Carries the HTTP +status+, the API +error_type+ (e.g. "plan", "scope",
|
|
9
|
+
# "state", "entitlement", "account") and the parsed response +body+ when
|
|
10
|
+
# one was present.
|
|
11
|
+
class Error < StandardError
|
|
12
|
+
attr_reader :status, :error_type, :body
|
|
13
|
+
|
|
14
|
+
def initialize(message = nil, status: nil, error_type: nil, body: nil)
|
|
15
|
+
super(message || self.class.name)
|
|
16
|
+
@status = status
|
|
17
|
+
@error_type = error_type
|
|
18
|
+
@body = body
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Maps an HTTP error response to the matching error subclass.
|
|
22
|
+
# +headers+ must use lower-case keys.
|
|
23
|
+
def self.from_response(status, raw_body, headers = {})
|
|
24
|
+
body = parse_body(raw_body)
|
|
25
|
+
info = body.is_a?(Hash) ? body : {}
|
|
26
|
+
error_obj = info["error"].is_a?(Hash) ? info["error"] : {}
|
|
27
|
+
message = info["message"]
|
|
28
|
+
message ||= info["error"] if info["error"].is_a?(String)
|
|
29
|
+
message ||= "HTTP #{status}"
|
|
30
|
+
common = { status: status, error_type: error_obj["type"], body: body }
|
|
31
|
+
|
|
32
|
+
case status
|
|
33
|
+
when 401
|
|
34
|
+
AuthenticationError.new(message, **common)
|
|
35
|
+
when 403
|
|
36
|
+
AccessError.new(message, required_scope: error_obj["required"], **common)
|
|
37
|
+
when 404
|
|
38
|
+
NotFoundError.new(message, **common)
|
|
39
|
+
when 409
|
|
40
|
+
StateConflictError.new(message, current: error_obj["current"], **common)
|
|
41
|
+
when 422
|
|
42
|
+
if error_obj["type"] == "entitlement"
|
|
43
|
+
EntitlementError.new(message, **common)
|
|
44
|
+
else
|
|
45
|
+
ValidationError.new(message, errors: info["errors"] || {}, **common)
|
|
46
|
+
end
|
|
47
|
+
when 429
|
|
48
|
+
retry_after = headers["retry-after"]
|
|
49
|
+
RateLimitError.new(message, retry_after: retry_after&.to_i, **common)
|
|
50
|
+
when 500..599
|
|
51
|
+
ServerError.new(message, **common)
|
|
52
|
+
else
|
|
53
|
+
Error.new(message, **common)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.parse_body(raw_body)
|
|
58
|
+
return raw_body unless raw_body.is_a?(String)
|
|
59
|
+
return nil if raw_body.empty?
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
JSON.parse(raw_body)
|
|
63
|
+
rescue JSON::ParserError
|
|
64
|
+
raw_body
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
private_class_method :parse_body
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# 401 — missing or invalid API token.
|
|
71
|
+
class AuthenticationError < Error; end
|
|
72
|
+
|
|
73
|
+
# 403 — the token or plan does not allow this action.
|
|
74
|
+
# +error_type+ is "plan", "scope" or "account"; +required_scope+ carries
|
|
75
|
+
# the missing token scope when the API reports one.
|
|
76
|
+
class AccessError < Error
|
|
77
|
+
attr_reader :required_scope
|
|
78
|
+
alias required required_scope
|
|
79
|
+
|
|
80
|
+
def initialize(message = nil, required_scope: nil, **options)
|
|
81
|
+
super(message, **options)
|
|
82
|
+
@required_scope = required_scope
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# 404 — resource not found (or not yours).
|
|
87
|
+
class NotFoundError < Error; end
|
|
88
|
+
|
|
89
|
+
# 422 — invalid input. +errors+ maps field names to arrays of messages.
|
|
90
|
+
class ValidationError < Error
|
|
91
|
+
attr_reader :errors
|
|
92
|
+
|
|
93
|
+
def initialize(message = nil, errors: {}, **options)
|
|
94
|
+
super(message, **options)
|
|
95
|
+
@errors = errors
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# 422 with error.type = "entitlement" — a plan feature or limit was hit.
|
|
100
|
+
class EntitlementError < Error; end
|
|
101
|
+
|
|
102
|
+
# 409 — invalid state transition. +current+ is the link's current status.
|
|
103
|
+
class StateConflictError < Error
|
|
104
|
+
attr_reader :current
|
|
105
|
+
|
|
106
|
+
def initialize(message = nil, current: nil, **options)
|
|
107
|
+
super(message, **options)
|
|
108
|
+
@current = current
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# 429 — rate limited. +retry_after+ is the wait in seconds when the API
|
|
113
|
+
# sent a Retry-After header.
|
|
114
|
+
class RateLimitError < Error
|
|
115
|
+
attr_reader :retry_after
|
|
116
|
+
|
|
117
|
+
def initialize(message = nil, retry_after: nil, **options)
|
|
118
|
+
super(message, **options)
|
|
119
|
+
@retry_after = retry_after
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# 5xx — something went wrong on the in.bio side.
|
|
124
|
+
class ServerError < Error; end
|
|
125
|
+
|
|
126
|
+
# Webhook signature could not be verified.
|
|
127
|
+
class SignatureVerificationError < Error; end
|
|
128
|
+
end
|
data/lib/inbio/models.rb
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inbio
|
|
4
|
+
# Base for all API models. Subclasses declare their known fields with
|
|
5
|
+
# +fields+; each becomes an attr_reader populated from the response hash.
|
|
6
|
+
# The full untouched hash stays available via +raw+ (and +[]+), so unknown
|
|
7
|
+
# or newly added API fields never crash parsing and are never lost.
|
|
8
|
+
class Model
|
|
9
|
+
class << self
|
|
10
|
+
def fields(*names)
|
|
11
|
+
@field_names ||= []
|
|
12
|
+
names.each do |name|
|
|
13
|
+
@field_names << name
|
|
14
|
+
attr_reader name
|
|
15
|
+
end
|
|
16
|
+
@field_names
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_reader :raw
|
|
21
|
+
|
|
22
|
+
def initialize(attributes = {})
|
|
23
|
+
@raw = attributes.is_a?(Hash) ? attributes : {}
|
|
24
|
+
self.class.fields.each do |field|
|
|
25
|
+
instance_variable_set(:"@#{field}", @raw[field.to_s])
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Access any field by name, including ones this SDK version doesn't
|
|
30
|
+
# know about yet.
|
|
31
|
+
def [](key)
|
|
32
|
+
@raw[key.to_s]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_h
|
|
36
|
+
@raw
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ==(other)
|
|
40
|
+
other.is_a?(self.class) && other.raw == raw
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def inspect
|
|
44
|
+
pairs = self.class.fields.map { |f| "#{f}=#{instance_variable_get(:"@#{f}").inspect}" }
|
|
45
|
+
"#<#{self.class.name} #{pairs.join(' ')}>"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class Link < Model
|
|
50
|
+
fields :id, :slug, :short_url, :destination_url, :title, :description,
|
|
51
|
+
:status, :redirect_type, :folder_id, :tags, :expires_at,
|
|
52
|
+
:click_limit, :has_password, :total_clicks, :unique_clicks,
|
|
53
|
+
:last_clicked_at, :created_at, :updated_at
|
|
54
|
+
|
|
55
|
+
def has_password?
|
|
56
|
+
!!has_password
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class Folder < Model
|
|
61
|
+
fields :id, :name, :color, :position, :links_count, :created_at
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class Tag < Model
|
|
65
|
+
fields :id, :name, :links_count, :created_at
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# range: {"from" => ..., "to" => ...}
|
|
69
|
+
# totals: {"clicks" => ..., "uniques" => ..., "botClicks" => ...}
|
|
70
|
+
# series/countries/devices/browsers/referrers: arrays of hashes as documented.
|
|
71
|
+
class Analytics < Model
|
|
72
|
+
fields :range, :totals, :series, :countries, :devices, :browsers, :referrers
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# usage/limits are hashes, e.g. usage["links_created"], limits["links_per_month"].
|
|
76
|
+
class Usage < Model
|
|
77
|
+
fields :plan, :period_start, :usage, :limits
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Result of the free keyless POST /api/shorten endpoint.
|
|
81
|
+
class ShortenResult < Model
|
|
82
|
+
fields :short_url, :slug, :qr_url, :preview_url, :claim_url, :expires
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A verified webhook delivery.
|
|
86
|
+
class WebhookEvent < Model
|
|
87
|
+
fields :id, :event, :created_at, :data
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Result of links.bulk_create: +created+ is an array of Link, +failed+ an
|
|
91
|
+
# array of {"index" => i, "error" => "..."} hashes.
|
|
92
|
+
class BulkResult
|
|
93
|
+
attr_reader :created, :failed, :raw
|
|
94
|
+
|
|
95
|
+
def initialize(attributes = {})
|
|
96
|
+
@raw = attributes.is_a?(Hash) ? attributes : {}
|
|
97
|
+
@created = Array(@raw["created"]).map { |link| Link.new(link) }
|
|
98
|
+
@failed = Array(@raw["failed"])
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
data/lib/inbio/page.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inbio
|
|
4
|
+
# One page of a paginated list response ({data, links, meta}).
|
|
5
|
+
# Enumerable over the models on this page.
|
|
6
|
+
class Page
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
attr_reader :data, :links, :meta, :raw
|
|
10
|
+
|
|
11
|
+
def initialize(payload, model:)
|
|
12
|
+
@raw = payload.is_a?(Hash) ? payload : {}
|
|
13
|
+
@data = Array(@raw["data"]).map { |item| model.new(item) }
|
|
14
|
+
@links = @raw["links"].is_a?(Hash) ? @raw["links"] : {}
|
|
15
|
+
@meta = @raw["meta"].is_a?(Hash) ? @raw["meta"] : {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def each(&block)
|
|
19
|
+
@data.each(&block)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def total
|
|
23
|
+
@meta["total"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def current_page
|
|
27
|
+
@meta["current_page"]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def per_page
|
|
31
|
+
@meta["per_page"]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True when the API reports another page after this one.
|
|
35
|
+
def next?
|
|
36
|
+
!@links["next"].nil?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inbio
|
|
4
|
+
module Resources
|
|
5
|
+
class Account
|
|
6
|
+
def initialize(client)
|
|
7
|
+
@client = client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# GET /api/v1/account/usage → Inbio::Usage
|
|
11
|
+
def usage
|
|
12
|
+
Usage.new(@client.request(:get, "/api/v1/account/usage")["data"])
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inbio
|
|
4
|
+
module Resources
|
|
5
|
+
class Folders
|
|
6
|
+
def initialize(client)
|
|
7
|
+
@client = client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# GET /api/v1/folders → [Inbio::Folder]
|
|
11
|
+
def list
|
|
12
|
+
Array(@client.request(:get, "/api/v1/folders")["data"]).map { |folder| Folder.new(folder) }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inbio
|
|
4
|
+
module Resources
|
|
5
|
+
class Links
|
|
6
|
+
def initialize(client)
|
|
7
|
+
@client = client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# GET /api/v1/links — one page of links.
|
|
11
|
+
def list(search: nil, folder_id: nil, tag: nil, status: nil, page: nil, per_page: nil)
|
|
12
|
+
query = compact(
|
|
13
|
+
search: search, folder_id: folder_id, tag: tag,
|
|
14
|
+
status: status, page: page, per_page: per_page
|
|
15
|
+
)
|
|
16
|
+
Page.new(@client.request(:get, "/api/v1/links", query: query), model: Link)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Lazily enumerates every link across all pages. Returns an Enumerator;
|
|
20
|
+
# no request is made until you start consuming it.
|
|
21
|
+
def iterate(search: nil, folder_id: nil, tag: nil, status: nil, per_page: nil, page: 1)
|
|
22
|
+
Enumerator.new do |yielder|
|
|
23
|
+
current = page || 1
|
|
24
|
+
loop do
|
|
25
|
+
result = list(
|
|
26
|
+
search: search, folder_id: folder_id, tag: tag,
|
|
27
|
+
status: status, per_page: per_page, page: current
|
|
28
|
+
)
|
|
29
|
+
result.data.each { |link| yielder << link }
|
|
30
|
+
break unless result.next?
|
|
31
|
+
|
|
32
|
+
current += 1
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
alias list_all iterate
|
|
37
|
+
|
|
38
|
+
# POST /api/v1/links
|
|
39
|
+
def create(destination_url, slug: nil, title: nil, description: nil, notes: nil,
|
|
40
|
+
redirect_type: nil, folder_id: nil, tags: nil, expires_at: nil,
|
|
41
|
+
fallback_url: nil, click_limit: nil, password: nil, utm: nil)
|
|
42
|
+
body = compact(
|
|
43
|
+
destination_url: destination_url, slug: slug, title: title,
|
|
44
|
+
description: description, notes: notes, redirect_type: redirect_type,
|
|
45
|
+
folder_id: folder_id, tags: tags, expires_at: expires_at,
|
|
46
|
+
fallback_url: fallback_url, click_limit: click_limit,
|
|
47
|
+
password: password, utm: utm
|
|
48
|
+
)
|
|
49
|
+
Link.new(@client.request(:post, "/api/v1/links", body: body)["data"])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# POST /api/v1/links/bulk — up to 100 link objects; rows fail independently.
|
|
53
|
+
def bulk_create(links)
|
|
54
|
+
payload = links.map { |link| link.respond_to?(:to_h) ? link.to_h : link }
|
|
55
|
+
BulkResult.new(@client.request(:post, "/api/v1/links/bulk", body: { links: payload })["data"])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# GET /api/v1/links/{id}
|
|
59
|
+
def get(id)
|
|
60
|
+
Link.new(@client.request(:get, "/api/v1/links/#{id}")["data"])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# PATCH /api/v1/links/{id} — any subset of the create fields, plus slug.
|
|
64
|
+
def update(id, **fields)
|
|
65
|
+
Link.new(@client.request(:patch, "/api/v1/links/#{id}", body: fields)["data"])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# DELETE /api/v1/links/{id} — 204, returns nil.
|
|
69
|
+
def delete(id)
|
|
70
|
+
@client.request(:delete, "/api/v1/links/#{id}")
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# POST /api/v1/links/{id}/enable
|
|
75
|
+
def enable(id)
|
|
76
|
+
Link.new(@client.request(:post, "/api/v1/links/#{id}/enable")["data"])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# POST /api/v1/links/{id}/disable
|
|
80
|
+
def disable(id)
|
|
81
|
+
Link.new(@client.request(:post, "/api/v1/links/#{id}/disable")["data"])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# GET /api/v1/links/{id}/qr — returns raw image bytes (PNG by default).
|
|
85
|
+
# Write them straight to a file: File.binwrite("qr.png", client.links.qr(id))
|
|
86
|
+
def qr(id, format: nil, size: nil)
|
|
87
|
+
@client.request(:get, "/api/v1/links/#{id}/qr", query: compact(format: format, size: size), raw: true)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# GET /api/v1/links/{id}/analytics — dates as "YYYY-MM-DD" strings or Date.
|
|
91
|
+
def analytics(id, from: nil, to: nil)
|
|
92
|
+
Analytics.new(@client.request(:get, "/api/v1/links/#{id}/analytics", query: compact(from: from, to: to))["data"])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def compact(hash)
|
|
98
|
+
hash.reject { |_, value| value.nil? }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inbio
|
|
4
|
+
module Resources
|
|
5
|
+
class Tags
|
|
6
|
+
def initialize(client)
|
|
7
|
+
@client = client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# GET /api/v1/tags → [Inbio::Tag]
|
|
11
|
+
def list
|
|
12
|
+
Array(@client.request(:get, "/api/v1/tags")["data"]).map { |tag| Tag.new(tag) }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "openssl"
|
|
6
|
+
|
|
7
|
+
module Inbio
|
|
8
|
+
# Default HTTP transport built on net/http. All HTTP performed by the SDK
|
|
9
|
+
# goes through a single object with this interface, so tests (or advanced
|
|
10
|
+
# users) can swap it via <tt>Inbio::Client.new(transport: ...)</tt>.
|
|
11
|
+
#
|
|
12
|
+
# The interface is a single method:
|
|
13
|
+
#
|
|
14
|
+
# call(method, url, headers:, body:, timeout:) -> Inbio::Transport::Response
|
|
15
|
+
class Transport
|
|
16
|
+
Response = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
METHODS = {
|
|
19
|
+
get: Net::HTTP::Get,
|
|
20
|
+
post: Net::HTTP::Post,
|
|
21
|
+
patch: Net::HTTP::Patch,
|
|
22
|
+
put: Net::HTTP::Put,
|
|
23
|
+
delete: Net::HTTP::Delete
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def call(method, url, headers:, body: nil, timeout: 30)
|
|
27
|
+
uri = URI.parse(url)
|
|
28
|
+
klass = METHODS.fetch(method) { raise ArgumentError, "unsupported HTTP method: #{method.inspect}" }
|
|
29
|
+
|
|
30
|
+
request = klass.new(uri)
|
|
31
|
+
headers.each { |name, value| request[name] = value }
|
|
32
|
+
request.body = body if body
|
|
33
|
+
|
|
34
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
35
|
+
http.use_ssl = uri.scheme == "https"
|
|
36
|
+
http.open_timeout = timeout
|
|
37
|
+
http.read_timeout = timeout
|
|
38
|
+
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
|
|
39
|
+
|
|
40
|
+
response = http.start { |conn| conn.request(request) }
|
|
41
|
+
|
|
42
|
+
response_headers = {}
|
|
43
|
+
response.each_header { |name, value| response_headers[name.downcase] = value }
|
|
44
|
+
|
|
45
|
+
Response.new(status: response.code.to_i, headers: response_headers, body: response.body)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Inbio
|
|
7
|
+
# Pure-crypto webhook signature verification — no HTTP involved.
|
|
8
|
+
#
|
|
9
|
+
# in.bio signs each delivery with the header
|
|
10
|
+
# X-Inbio-Signature: t=<unix>,v1=<hex>
|
|
11
|
+
# where v1 = HMAC-SHA256(secret, "<t>.<raw body>").
|
|
12
|
+
class Webhooks
|
|
13
|
+
DEFAULT_TOLERANCE = 300
|
|
14
|
+
|
|
15
|
+
# Verifies a webhook delivery and returns the parsed Inbio::WebhookEvent.
|
|
16
|
+
#
|
|
17
|
+
# +payload+ must be the exact raw request body string (before any JSON
|
|
18
|
+
# parsing), +signature_header+ the value of X-Inbio-Signature, +secret+
|
|
19
|
+
# your endpoint's signing secret. Deliveries whose timestamp differs from
|
|
20
|
+
# now by more than +tolerance+ seconds are rejected to prevent replay
|
|
21
|
+
# attacks (pass tolerance: nil to skip the timestamp check).
|
|
22
|
+
#
|
|
23
|
+
# Raises Inbio::SignatureVerificationError when verification fails.
|
|
24
|
+
def self.verify(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE)
|
|
25
|
+
timestamp, signatures = parse_header(signature_header)
|
|
26
|
+
|
|
27
|
+
if tolerance
|
|
28
|
+
age = (Time.now.to_i - timestamp).abs
|
|
29
|
+
if age > tolerance
|
|
30
|
+
raise SignatureVerificationError.new(
|
|
31
|
+
"Webhook timestamp outside the allowed tolerance (#{age}s > #{tolerance}s)"
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
|
|
37
|
+
unless signatures.any? { |signature| secure_compare(expected, signature) }
|
|
38
|
+
raise SignatureVerificationError.new("Webhook signature does not match the expected signature")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
WebhookEvent.new(JSON.parse(payload))
|
|
42
|
+
rescue JSON::ParserError
|
|
43
|
+
raise SignatureVerificationError.new("Webhook payload is not valid JSON")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
alias construct_event verify
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.parse_header(header)
|
|
51
|
+
unless header.is_a?(String) && !header.empty?
|
|
52
|
+
raise SignatureVerificationError.new("Missing X-Inbio-Signature header")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
timestamp = nil
|
|
56
|
+
signatures = []
|
|
57
|
+
header.split(",").each do |pair|
|
|
58
|
+
key, _, value = pair.strip.partition("=")
|
|
59
|
+
case key
|
|
60
|
+
when "t" then timestamp = value
|
|
61
|
+
when "v1" then signatures << value unless value.empty?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
unless timestamp&.match?(/\A\d+\z/) && !signatures.empty?
|
|
66
|
+
raise SignatureVerificationError.new("Malformed X-Inbio-Signature header")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
[timestamp.to_i, signatures]
|
|
70
|
+
end
|
|
71
|
+
private_class_method :parse_header
|
|
72
|
+
|
|
73
|
+
def self.secure_compare(a, b)
|
|
74
|
+
return false unless a.bytesize == b.bytesize
|
|
75
|
+
|
|
76
|
+
if OpenSSL.respond_to?(:fixed_length_secure_compare)
|
|
77
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
78
|
+
else
|
|
79
|
+
result = 0
|
|
80
|
+
a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
|
|
81
|
+
result.zero?
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
private_class_method :secure_compare
|
|
85
|
+
|
|
86
|
+
# Instance methods so the helper is also reachable as client.webhooks.
|
|
87
|
+
def verify(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE)
|
|
88
|
+
self.class.verify(payload, signature_header, secret, tolerance: tolerance)
|
|
89
|
+
end
|
|
90
|
+
alias construct_event verify
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/inbio.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "inbio/version"
|
|
4
|
+
require_relative "inbio/errors"
|
|
5
|
+
require_relative "inbio/transport"
|
|
6
|
+
require_relative "inbio/models"
|
|
7
|
+
require_relative "inbio/page"
|
|
8
|
+
require_relative "inbio/webhooks"
|
|
9
|
+
require_relative "inbio/resources/links"
|
|
10
|
+
require_relative "inbio/resources/folders"
|
|
11
|
+
require_relative "inbio/resources/tags"
|
|
12
|
+
require_relative "inbio/resources/account"
|
|
13
|
+
require_relative "inbio/client"
|
|
14
|
+
|
|
15
|
+
# Official Ruby SDK for the in.bio URL shortener API.
|
|
16
|
+
#
|
|
17
|
+
# # Free, keyless:
|
|
18
|
+
# Inbio.shorten("https://example.com/long").short_url
|
|
19
|
+
#
|
|
20
|
+
# # Authenticated:
|
|
21
|
+
# client = Inbio::Client.new(token: "...")
|
|
22
|
+
# client.links.create("https://example.com/sale", slug: "sale")
|
|
23
|
+
module Inbio
|
|
24
|
+
# Shortens a URL through the free keyless endpoint (POST /api/shorten).
|
|
25
|
+
# No account or token needed. Accepts the same options as Inbio::Client.new.
|
|
26
|
+
def self.shorten(url, **options)
|
|
27
|
+
Client.new(**options).shorten(url)
|
|
28
|
+
end
|
|
29
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: inbio
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- INBIO
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'Official Ruby client for the in.bio URL shortener: short links, QR codes,
|
|
13
|
+
analytics, folders, tags, bulk create and webhook signature verification. Zero runtime
|
|
14
|
+
dependencies.'
|
|
15
|
+
email:
|
|
16
|
+
- support@in.bio
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- lib/inbio.rb
|
|
24
|
+
- lib/inbio/client.rb
|
|
25
|
+
- lib/inbio/errors.rb
|
|
26
|
+
- lib/inbio/models.rb
|
|
27
|
+
- lib/inbio/page.rb
|
|
28
|
+
- lib/inbio/resources/account.rb
|
|
29
|
+
- lib/inbio/resources/folders.rb
|
|
30
|
+
- lib/inbio/resources/links.rb
|
|
31
|
+
- lib/inbio/resources/tags.rb
|
|
32
|
+
- lib/inbio/transport.rb
|
|
33
|
+
- lib/inbio/version.rb
|
|
34
|
+
- lib/inbio/webhooks.rb
|
|
35
|
+
homepage: https://in.bio
|
|
36
|
+
licenses:
|
|
37
|
+
- MIT
|
|
38
|
+
metadata:
|
|
39
|
+
source_code_uri: https://github.com/getinbio/inbio-ruby
|
|
40
|
+
bug_tracker_uri: https://github.com/getinbio/inbio-ruby/issues
|
|
41
|
+
homepage_uri: https://in.bio
|
|
42
|
+
documentation_uri: https://docs.in.bio
|
|
43
|
+
rubygems_mfa_required: 'true'
|
|
44
|
+
rdoc_options: []
|
|
45
|
+
require_paths:
|
|
46
|
+
- lib
|
|
47
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '3.0'
|
|
52
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '0'
|
|
57
|
+
requirements: []
|
|
58
|
+
rubygems_version: 4.0.3
|
|
59
|
+
specification_version: 4
|
|
60
|
+
summary: Official Ruby SDK for the in.bio URL shortener API
|
|
61
|
+
test_files: []
|