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 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)
@@ -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
@@ -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
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inbio
4
+ VERSION = "0.1.0"
5
+ 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: []