rerout 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.
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rerout
4
+ # Plain-old Ruby value objects returned by the Rerout API. Each model is a
5
+ # frozen struct-like class with `from_hash` for JSON ingestion and value
6
+ # equality semantics.
7
+ module Models
8
+ # A short link.
9
+ class Link
10
+ ATTRS = %i[
11
+ code short_url domain_hostname target_url project_id expires_at
12
+ is_active seo_title seo_description seo_image_url seo_canonical_url
13
+ seo_noindex seo_updated_at created_at updated_at
14
+ ].freeze
15
+
16
+ attr_reader(*ATTRS)
17
+
18
+ def initialize(**attrs)
19
+ ATTRS.each { |k| instance_variable_set(:"@#{k}", attrs[k]) }
20
+ freeze
21
+ end
22
+
23
+ def self.from_hash(hash)
24
+ new(
25
+ code: hash['code'],
26
+ short_url: hash['short_url'],
27
+ domain_hostname: hash['domain_hostname'],
28
+ target_url: hash['target_url'],
29
+ project_id: hash['project_id'],
30
+ expires_at: hash['expires_at'],
31
+ is_active: hash['is_active'],
32
+ seo_title: hash['seo_title'],
33
+ seo_description: hash['seo_description'],
34
+ seo_image_url: hash['seo_image_url'],
35
+ seo_canonical_url: hash['seo_canonical_url'],
36
+ seo_noindex: hash.fetch('seo_noindex', true),
37
+ seo_updated_at: hash['seo_updated_at'],
38
+ created_at: hash['created_at'],
39
+ updated_at: hash['updated_at']
40
+ )
41
+ end
42
+
43
+ def to_h
44
+ ATTRS.to_h { |k| [k, public_send(k)] }
45
+ end
46
+
47
+ def ==(other)
48
+ other.is_a?(Link) && other.to_h == to_h
49
+ end
50
+ alias eql? ==
51
+
52
+ def hash
53
+ to_h.hash
54
+ end
55
+ end
56
+
57
+ # A breakdown bucket — `{ value: "ZA", clicks: 42 }`.
58
+ class StatsBreakdown
59
+ attr_reader :value, :clicks
60
+
61
+ def initialize(value:, clicks:)
62
+ @value = value
63
+ @clicks = clicks
64
+ freeze
65
+ end
66
+
67
+ def self.from_hash(hash)
68
+ new(value: hash['value'], clicks: hash['clicks'])
69
+ end
70
+
71
+ def to_h
72
+ { value: value, clicks: clicks }
73
+ end
74
+
75
+ def ==(other)
76
+ other.is_a?(StatsBreakdown) && other.value == value && other.clicks == clicks
77
+ end
78
+ alias eql? ==
79
+
80
+ def hash
81
+ [self.class, value, clicks].hash
82
+ end
83
+ end
84
+
85
+ # One day in a `daily` time-series.
86
+ class DailyClicksPoint
87
+ attr_reader :day, :clicks, :qr_scans
88
+
89
+ def initialize(day:, clicks:, qr_scans:)
90
+ @day = day
91
+ @clicks = clicks
92
+ @qr_scans = qr_scans
93
+ freeze
94
+ end
95
+
96
+ def self.from_hash(hash)
97
+ new(day: hash['day'], clicks: hash['clicks'], qr_scans: hash['qr_scans'])
98
+ end
99
+
100
+ def to_h
101
+ { day: day, clicks: clicks, qr_scans: qr_scans }
102
+ end
103
+
104
+ def ==(other)
105
+ other.is_a?(DailyClicksPoint) && other.day == day &&
106
+ other.clicks == clicks && other.qr_scans == qr_scans
107
+ end
108
+ alias eql? ==
109
+
110
+ def hash
111
+ [self.class, day, clicks, qr_scans].hash
112
+ end
113
+ end
114
+
115
+ # Per-link click stats response.
116
+ class LinkStats
117
+ attr_reader :code, :days, :total_clicks, :qr_scans, :countries, :referrers
118
+
119
+ def initialize(code:, days:, total_clicks:, qr_scans:, countries:, referrers:)
120
+ @code = code
121
+ @days = days
122
+ @total_clicks = total_clicks
123
+ @qr_scans = qr_scans
124
+ @countries = countries.freeze
125
+ @referrers = referrers.freeze
126
+ freeze
127
+ end
128
+
129
+ def self.from_hash(hash)
130
+ new(
131
+ code: hash['code'],
132
+ days: hash['days'],
133
+ total_clicks: hash['total_clicks'],
134
+ qr_scans: hash['qr_scans'],
135
+ countries: (hash['countries'] || []).map { |c| StatsBreakdown.from_hash(c) },
136
+ referrers: (hash['referrers'] || []).map { |c| StatsBreakdown.from_hash(c) }
137
+ )
138
+ end
139
+
140
+ def to_h
141
+ {
142
+ code: code, days: days, total_clicks: total_clicks, qr_scans: qr_scans,
143
+ countries: countries.map(&:to_h), referrers: referrers.map(&:to_h)
144
+ }
145
+ end
146
+
147
+ def ==(other)
148
+ other.is_a?(LinkStats) && other.to_h == to_h
149
+ end
150
+ alias eql? ==
151
+
152
+ def hash
153
+ to_h.hash
154
+ end
155
+ end
156
+
157
+ # Project-wide aggregate stats response.
158
+ class ProjectStats
159
+ attr_reader :days, :total_clicks, :qr_scans, :daily, :countries, :referrers,
160
+ :devices, :browsers, :top_codes
161
+
162
+ def initialize(**attrs)
163
+ @days = attrs[:days]
164
+ @total_clicks = attrs[:total_clicks]
165
+ @qr_scans = attrs[:qr_scans]
166
+ @daily = attrs[:daily].freeze
167
+ @countries = attrs[:countries].freeze
168
+ @referrers = attrs[:referrers].freeze
169
+ @devices = attrs[:devices].freeze
170
+ @browsers = attrs[:browsers].freeze
171
+ @top_codes = attrs[:top_codes].freeze
172
+ freeze
173
+ end
174
+
175
+ def self.from_hash(hash)
176
+ new(
177
+ days: hash['days'],
178
+ total_clicks: hash['total_clicks'],
179
+ qr_scans: hash['qr_scans'],
180
+ daily: (hash['daily'] || []).map { |d| DailyClicksPoint.from_hash(d) },
181
+ countries: (hash['countries'] || []).map { |b| StatsBreakdown.from_hash(b) },
182
+ referrers: (hash['referrers'] || []).map { |b| StatsBreakdown.from_hash(b) },
183
+ devices: (hash['devices'] || []).map { |b| StatsBreakdown.from_hash(b) },
184
+ browsers: (hash['browsers'] || []).map { |b| StatsBreakdown.from_hash(b) },
185
+ top_codes: (hash['top_codes'] || []).map { |b| StatsBreakdown.from_hash(b) }
186
+ )
187
+ end
188
+
189
+ def to_h
190
+ {
191
+ days: days, total_clicks: total_clicks, qr_scans: qr_scans,
192
+ daily: daily.map(&:to_h),
193
+ countries: countries.map(&:to_h),
194
+ referrers: referrers.map(&:to_h),
195
+ devices: devices.map(&:to_h),
196
+ browsers: browsers.map(&:to_h),
197
+ top_codes: top_codes.map(&:to_h)
198
+ }
199
+ end
200
+
201
+ def ==(other)
202
+ other.is_a?(ProjectStats) && other.to_h == to_h
203
+ end
204
+ alias eql? ==
205
+
206
+ def hash
207
+ to_h.hash
208
+ end
209
+ end
210
+
211
+ # Paginated list of links.
212
+ class ListLinksResult
213
+ attr_reader :links, :next_cursor
214
+
215
+ def initialize(links:, next_cursor:)
216
+ @links = links.freeze
217
+ @next_cursor = next_cursor
218
+ freeze
219
+ end
220
+
221
+ def self.from_hash(hash)
222
+ new(
223
+ links: (hash['links'] || []).map { |l| Link.from_hash(l) },
224
+ next_cursor: hash['next_cursor']
225
+ )
226
+ end
227
+
228
+ def to_h
229
+ { links: links.map(&:to_h), next_cursor: next_cursor }
230
+ end
231
+
232
+ def ==(other)
233
+ other.is_a?(ListLinksResult) && other.to_h == to_h
234
+ end
235
+ alias eql? ==
236
+
237
+ def hash
238
+ to_h.hash
239
+ end
240
+ end
241
+
242
+ # Project identity envelope.
243
+ class Project
244
+ attr_reader :id, :name, :slug
245
+
246
+ def initialize(id:, name:, slug:)
247
+ @id = id
248
+ @name = name
249
+ @slug = slug
250
+ freeze
251
+ end
252
+
253
+ def self.from_hash(hash)
254
+ new(id: hash['id'], name: hash['name'], slug: hash['slug'])
255
+ end
256
+
257
+ def to_h
258
+ { id: id, name: name, slug: slug }
259
+ end
260
+
261
+ def ==(other)
262
+ other.is_a?(Project) && other.to_h == to_h
263
+ end
264
+ alias eql? ==
265
+
266
+ def hash
267
+ [self.class, id, name, slug].hash
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rerout
4
+ module Resources
5
+ # Project-level operations namespace.
6
+ class Project
7
+ # @param client [Rerout::Client]
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ # Aggregate stats across every link in the project. Defaults to 30 days.
13
+ #
14
+ # @param days [Integer]
15
+ # @return [Rerout::Models::ProjectStats]
16
+ def stats(days: 30)
17
+ response = @client.request(
18
+ method: :get,
19
+ path: '/v1/projects/me/stats',
20
+ query: { 'days' => days }
21
+ )
22
+ Models::ProjectStats.from_hash(response)
23
+ end
24
+
25
+ # Info about the project that owns the current API key.
26
+ #
27
+ # @return [Rerout::Models::Project]
28
+ def me
29
+ response = @client.request(method: :get, path: '/v1/projects/me')
30
+ Models::Project.from_hash(response)
31
+ end
32
+ end
33
+ end
34
+ end
data/lib/rerout/qr.rb ADDED
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'uri'
5
+
6
+ module Rerout
7
+ module Resources
8
+ # QR helpers.
9
+ class Qr
10
+ # @param client [Rerout::Client]
11
+ def initialize(client)
12
+ @client = client
13
+ end
14
+
15
+ # Build the URL the API serves the QR SVG from. Pure — does not hit
16
+ # the network.
17
+ #
18
+ # @param code [String]
19
+ # @param options [Rerout::QrOptions, Hash, nil]
20
+ # @return [String]
21
+ def url(code, options = nil)
22
+ Qr.build_url(base_url: @client.base_url, code: code, options: options)
23
+ end
24
+
25
+ # Fetch the QR as an SVG string. Attaches the bearer token.
26
+ #
27
+ # @param code [String]
28
+ # @param options [Rerout::QrOptions, Hash, nil]
29
+ # @return [String] the rendered SVG markup.
30
+ def svg(code, options = nil)
31
+ raise ArgumentError, 'code is required' if code.nil? || code.to_s.empty?
32
+
33
+ qopts = Qr.coerce_options(options)
34
+ path = "/v1/links/#{ERB::Util.url_encode(code.to_s)}/qr"
35
+ @client.request(
36
+ method: :get,
37
+ path: path,
38
+ query: qopts.to_query_hash,
39
+ raw: true
40
+ )
41
+ end
42
+
43
+ # @api private — also used by callers that just want a URL without a client.
44
+ def self.build_url(base_url:, code:, options: nil)
45
+ raise ArgumentError, 'code is required' if code.nil? || code.to_s.empty?
46
+
47
+ qopts = coerce_options(options)
48
+ base = base_url.to_s.sub(%r{/+\z}, '')
49
+ path = "/v1/links/#{ERB::Util.url_encode(code.to_s)}/qr"
50
+ url = "#{base}#{path}"
51
+ query = qopts.to_query_hash
52
+ return url if query.empty?
53
+
54
+ "#{url}?#{URI.encode_www_form(query)}"
55
+ end
56
+
57
+ # @api private
58
+ def self.coerce_options(options)
59
+ case options
60
+ when nil then QrOptions.new
61
+ when QrOptions then options
62
+ when Hash then QrOptions.new(**options.transform_keys(&:to_sym))
63
+ else
64
+ raise ArgumentError, 'options must be a Rerout::QrOptions, Hash, or nil'
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rerout
4
+ # Options for the QR endpoint. All fields are optional.
5
+ #
6
+ # - `size` (Integer): module size in px (1..32). Server default 8.
7
+ # - `margin` (Integer): quiet zone in modules (0..16). Server default 4.
8
+ # - `ecc` (String): error correction level. One of `L`, `M`, `Q`, `H`.
9
+ # - `domain` (String): force the QR to encode a specific verified custom domain.
10
+ # - `refresh` (String, true): cache-bust on regenerate. `true` is serialized as `"1"`.
11
+ class QrOptions
12
+ attr_reader :size, :margin, :ecc, :domain, :refresh
13
+
14
+ ALLOWED_ECC = %w[L M Q H].freeze
15
+
16
+ def initialize(size: nil, margin: nil, ecc: nil, domain: nil, refresh: nil)
17
+ if ecc && !ALLOWED_ECC.include?(ecc.to_s)
18
+ raise ArgumentError, "ecc must be one of #{ALLOWED_ECC.inspect}, got #{ecc.inspect}"
19
+ end
20
+
21
+ @size = size
22
+ @margin = margin
23
+ @ecc = ecc
24
+ @domain = domain
25
+ @refresh = refresh
26
+ freeze
27
+ end
28
+
29
+ # @return [Boolean] true when no field is set.
30
+ def empty?
31
+ size.nil? && margin.nil? && ecc.nil? && domain.nil? && refresh.nil?
32
+ end
33
+
34
+ # Serialize options as a `{ key => string }` hash ready to be turned into
35
+ # a query string. `refresh: true` becomes `"1"`.
36
+ def to_query_hash
37
+ out = {}
38
+ out['size'] = size.to_s unless size.nil?
39
+ out['margin'] = margin.to_s unless margin.nil?
40
+ out['ecc'] = ecc.to_s unless ecc.nil?
41
+ out['domain'] = domain.to_s unless domain.nil?
42
+ unless refresh.nil?
43
+ out['refresh'] = refresh == true ? '1' : refresh.to_s
44
+ end
45
+ out
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rerout
4
+ # Sentinel singleton used to distinguish "leave the field alone" (not passed,
5
+ # default `Rerout::OMIT`) from "set the field to null on the server"
6
+ # (`Rerout::CLEAR`).
7
+ module ClearSentinel
8
+ def self.inspect
9
+ 'Rerout::CLEAR'
10
+ end
11
+ end
12
+
13
+ # @api private — internal default sentinel meaning "field not passed".
14
+ module OmitSentinel
15
+ def self.inspect
16
+ 'Rerout::OMIT'
17
+ end
18
+ end
19
+
20
+ # Public sentinel — pass `Rerout::CLEAR` to a nullable field on
21
+ # {Rerout::UpdateLinkInput} to send `null` on the wire.
22
+ #
23
+ # @example
24
+ # Rerout::UpdateLinkInput.new(seo_title: Rerout::CLEAR)
25
+ CLEAR = ClearSentinel
26
+ # Internal default — distinguishes "not provided" from "set to nil".
27
+ OMIT = OmitSentinel
28
+
29
+ # Request body for `PATCH /v1/links/:code`. Every field is optional. To
30
+ # send `null` to the server (clear an existing value), pass `Rerout::CLEAR`
31
+ # to that keyword argument.
32
+ #
33
+ # @example
34
+ # Rerout::UpdateLinkInput.new(target_url: 'https://new.example.com')
35
+ # Rerout::UpdateLinkInput.new(expires_at: Rerout::CLEAR)
36
+ class UpdateLinkInput
37
+ FIELDS = %i[
38
+ target_url expires_at is_active seo_title seo_description
39
+ seo_image_url seo_canonical_url seo_noindex
40
+ ].freeze
41
+
42
+ def initialize(target_url: OMIT, expires_at: OMIT, is_active: OMIT,
43
+ seo_title: OMIT, seo_description: OMIT, seo_image_url: OMIT,
44
+ seo_canonical_url: OMIT, seo_noindex: OMIT)
45
+ @values = {
46
+ target_url: target_url,
47
+ expires_at: expires_at,
48
+ is_active: is_active,
49
+ seo_title: seo_title,
50
+ seo_description: seo_description,
51
+ seo_image_url: seo_image_url,
52
+ seo_canonical_url: seo_canonical_url,
53
+ seo_noindex: seo_noindex
54
+ }
55
+ freeze
56
+ end
57
+
58
+ # Serialize for the wire. Unset fields are omitted; `Rerout::CLEAR`
59
+ # becomes `null`.
60
+ def to_h
61
+ out = {}
62
+ FIELDS.each do |field|
63
+ v = @values[field]
64
+ next if v.equal?(OMIT)
65
+
66
+ out[field.to_s] = v.equal?(CLEAR) ? nil : v
67
+ end
68
+ out
69
+ end
70
+
71
+ # @return [Boolean] true when no field was set.
72
+ def empty?
73
+ to_h.empty?
74
+ end
75
+
76
+ # @return [Object] the raw value (sentinel or actual) for a field.
77
+ def value_for(field)
78
+ @values.fetch(field)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rerout
4
+ # Library version. Follows semantic versioning.
5
+ VERSION = '0.1.0'
6
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Rerout
6
+ # Webhook signature verification.
7
+ #
8
+ # Rerout signs every webhook delivery with an `X-Rerout-Signature` header
9
+ # shaped as `t={unix_seconds},v1={hex_hmac_sha256}`. The HMAC is computed
10
+ # over `"{timestamp}.{raw_body}"` with the endpoint signing secret
11
+ # (`whsec_…`) as the key.
12
+ #
13
+ # @example Rack / Rails controller
14
+ # raw = request.body.read
15
+ # ok = Rerout::Webhooks.verify_signature(
16
+ # raw_body: raw,
17
+ # signature_header: request.headers['X-Rerout-Signature'],
18
+ # secret: ENV.fetch('REROUT_WEBHOOK_SECRET')
19
+ # )
20
+ # head(:unauthorized) and return unless ok
21
+ module Webhooks
22
+ # Default tolerance window in seconds between the `t=` timestamp and the
23
+ # current time. Five minutes — protects against captured-replay attacks.
24
+ DEFAULT_TOLERANCE_SECONDS = 300
25
+
26
+ # Verify a Rerout webhook signature.
27
+ #
28
+ # Returns `true` only when the header parses cleanly, the timestamp is
29
+ # within `tolerance_seconds` of `now`, and the computed HMAC matches the
30
+ # supplied `v1` in constant time. Returns `false` for every failure mode —
31
+ # it never raises.
32
+ #
33
+ # @param raw_body [String] the exact, unmodified request body bytes.
34
+ # @param signature_header [String] value of the `X-Rerout-Signature` header.
35
+ # @param secret [String] the endpoint signing secret (`whsec_…`).
36
+ # @param tolerance_seconds [Integer] staleness window. `0` disables the
37
+ # timestamp check entirely. Defaults to 300.
38
+ # @param now [Proc, #call, nil] injectable clock returning the current
39
+ # unix time in seconds. Defaults to `Time.now.to_i`. Useful for tests.
40
+ # @return [Boolean]
41
+ def self.verify_signature(raw_body:, signature_header:, secret:,
42
+ tolerance_seconds: DEFAULT_TOLERANCE_SECONDS,
43
+ now: nil)
44
+ return false if raw_body.nil?
45
+ return false if signature_header.nil? || signature_header.to_s.empty?
46
+ return false if secret.nil? || secret.to_s.empty?
47
+
48
+ parsed = parse_header(signature_header.to_s)
49
+ return false if parsed.nil?
50
+
51
+ timestamp, v1 = parsed
52
+ return false unless within_tolerance?(timestamp, tolerance_seconds, now)
53
+
54
+ expected = OpenSSL::HMAC.hexdigest('SHA256', secret.to_s, "#{timestamp}.#{raw_body}")
55
+ secure_compare(expected, v1)
56
+ end
57
+
58
+ # Parse `t=<unix>,v1=<hex>` (case-insensitive keys). Returns
59
+ # `[timestamp, v1]` or `nil` when the header is unusable.
60
+ #
61
+ # @api private
62
+ def self.parse_header(header)
63
+ timestamp = nil
64
+ v1 = nil
65
+ header.split(',').each do |segment|
66
+ eq = segment.index('=')
67
+ next if eq.nil? || eq.zero?
68
+
69
+ key = segment[0...eq].strip.downcase
70
+ value = segment[(eq + 1)..].strip
71
+ case key
72
+ when 't'
73
+ parsed = parse_timestamp(value)
74
+ timestamp = parsed unless parsed.nil?
75
+ when 'v1'
76
+ v1 = value unless value.empty?
77
+ end
78
+ end
79
+ return nil if timestamp.nil? || v1.nil?
80
+
81
+ [timestamp, v1]
82
+ end
83
+ private_class_method :parse_header
84
+
85
+ # Strict positive-integer parse — rejects `"abc"`, `"12x"`, `""`, `"-5"`.
86
+ #
87
+ # @api private
88
+ def self.parse_timestamp(value)
89
+ return nil unless value.match?(/\A\d+\z/)
90
+
91
+ parsed = value.to_i
92
+ parsed.positive? ? parsed : nil
93
+ end
94
+ private_class_method :parse_timestamp
95
+
96
+ # @api private
97
+ def self.within_tolerance?(timestamp, tolerance_seconds, now)
98
+ return true if tolerance_seconds.to_i <= 0
99
+
100
+ current = now ? now.call.to_i : Time.now.to_i
101
+ (current - timestamp).abs <= tolerance_seconds.to_i
102
+ end
103
+ private_class_method :within_tolerance?
104
+
105
+ # Constant-time string comparison over hex strings. Rejects non-hex or
106
+ # odd-length `v1` values before comparing.
107
+ #
108
+ # @api private
109
+ def self.secure_compare(expected, actual)
110
+ return false if actual.nil? || actual.empty?
111
+ return false unless actual.length.even?
112
+ return false unless actual.match?(/\A[0-9a-fA-F]+\z/)
113
+ return false unless expected.bytesize == actual.bytesize
114
+
115
+ OpenSSL.fixed_length_secure_compare(
116
+ expected.downcase, actual.downcase
117
+ )
118
+ rescue StandardError
119
+ false
120
+ end
121
+ private_class_method :secure_compare
122
+ end
123
+
124
+ # Module-level convenience matching the BRIEF's free-function shape.
125
+ #
126
+ # @see Rerout::Webhooks.verify_signature
127
+ # @return [Boolean]
128
+ def self.verify_signature(raw_body:, signature_header:, secret:,
129
+ tolerance_seconds: Webhooks::DEFAULT_TOLERANCE_SECONDS,
130
+ now: nil)
131
+ Webhooks.verify_signature(
132
+ raw_body: raw_body,
133
+ signature_header: signature_header,
134
+ secret: secret,
135
+ tolerance_seconds: tolerance_seconds,
136
+ now: now
137
+ )
138
+ end
139
+ end
data/lib/rerout.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rerout/version'
4
+ require_relative 'rerout/error'
5
+ require_relative 'rerout/models'
6
+ require_relative 'rerout/create_link_input'
7
+ require_relative 'rerout/update_link_input'
8
+ require_relative 'rerout/qr_options'
9
+ require_relative 'rerout/webhooks'
10
+ require_relative 'rerout/links'
11
+ require_relative 'rerout/project'
12
+ require_relative 'rerout/qr'
13
+ require_relative 'rerout/client'
14
+
15
+ # Official Ruby SDK for the Rerout branded-link API.
16
+ #
17
+ # Branded link infrastructure on Cloudflare — create short links, render QR
18
+ # codes, read analytics, and verify webhook signatures.
19
+ #
20
+ # @example Hello world
21
+ # require 'rerout'
22
+ #
23
+ # rerout = Rerout::Client.new(api_key: ENV.fetch('REROUT_API_KEY'))
24
+ # link = rerout.links.create(
25
+ # Rerout::CreateLinkInput.new(target_url: 'https://example.com/sale')
26
+ # )
27
+ # puts link.short_url
28
+ #
29
+ # @see https://rerout.co
30
+ # @see https://github.com/ModestNerds-Co/rerout-sdks
31
+ module Rerout
32
+ end