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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE +21 -0
- data/README.md +202 -0
- data/lib/rerout/client.rb +186 -0
- data/lib/rerout/create_link_input.rb +51 -0
- data/lib/rerout/error.rb +59 -0
- data/lib/rerout/links.rb +105 -0
- data/lib/rerout/models.rb +271 -0
- data/lib/rerout/project.rb +34 -0
- data/lib/rerout/qr.rb +69 -0
- data/lib/rerout/qr_options.rb +48 -0
- data/lib/rerout/update_link_input.rb +81 -0
- data/lib/rerout/version.rb +6 -0
- data/lib/rerout/webhooks.rb +139 -0
- data/lib/rerout.rb +32 -0
- metadata +148 -0
|
@@ -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,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
|