rerout 0.2.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +48 -0
- data/README.md +127 -1
- data/lib/rerout/client.rb +17 -1
- data/lib/rerout/conversions_resource.rb +37 -0
- data/lib/rerout/create_link_input.rb +23 -2
- data/lib/rerout/create_tag_input.rb +27 -0
- data/lib/rerout/create_webhook_input.rb +41 -0
- data/lib/rerout/input_serialization.rb +39 -0
- data/lib/rerout/links.rb +46 -0
- data/lib/rerout/models.rb +374 -2
- data/lib/rerout/tags_resource.rb +82 -0
- data/lib/rerout/update_link_input.rb +29 -4
- data/lib/rerout/update_tag_input.rb +37 -0
- data/lib/rerout/version.rb +1 -1
- data/lib/rerout/webhooks_resource.rb +63 -0
- data/lib/rerout.rb +7 -0
- metadata +10 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4d7253a49e955b1812e46e3d0c435999fda4c7276d5f19bad47586ac56d06247
|
|
4
|
+
data.tar.gz: aeb26215de9a5c1e3f5de9637d348342f7cac7461179a8db7b005a438d1260ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a4435295c93cc7e74812509c38d5cd7c829e004a37ec71796b3cb08b3707d7959ea4ba8d28aef0925d58bb48b00b39f8df807f4503af1fd33d880de3c4ffc04
|
|
7
|
+
data.tar.gz: ae6b3a652f246e38ca8537d4eda5699733de2fd3dcc1ed2e102eaca3e6e61041c1d8344082da8c764cdc35aeaa7c202c2818d6f0ed223bc10df376fdd5f658e6
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,53 @@ All notable changes to the `rerout` gem are documented in this file. The
|
|
|
4
4
|
format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.5.0] - 2026-06-20
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- New `tags` namespace for tag management — `client.tags.list`, `create`,
|
|
12
|
+
`update(tag_id, input)`, and `delete(tag_id)` against `/v1/projects/me/tags`
|
|
13
|
+
(API-key auth; project resolved from the key).
|
|
14
|
+
- `Rerout::CreateTagInput` (required `name`, optional `color`) and
|
|
15
|
+
`Rerout::UpdateTagInput` (both fields optional; omitted fields are left
|
|
16
|
+
unchanged, no client-side empty-payload check) request bodies. Both also
|
|
17
|
+
accept a plain Hash.
|
|
18
|
+
- New value models `Rerout::Models::TagSummary` (`Tag` plus `link_count`, the
|
|
19
|
+
list-response shape) and `Rerout::Models::ListTagsResult`. `tags.create` /
|
|
20
|
+
`tags.update` return the existing `Rerout::Models::Tag` (`{ id, name, color }`).
|
|
21
|
+
|
|
22
|
+
## [0.4.0] - 2026-06-04
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Smart Link fields on `Rerout::Models::Link`: `password_protected`,
|
|
27
|
+
`max_clicks`, `click_count`, `track_conversions`, `routing_rules` (array of
|
|
28
|
+
`RoutingRule`), and `ab_variants` (array of `AbVariant`).
|
|
29
|
+
- `Rerout::CreateLinkInput` gains optional `password`, `max_clicks`,
|
|
30
|
+
`track_conversions`, `routing_rules`, and `ab_variants`.
|
|
31
|
+
- `Rerout::UpdateLinkInput` gains `password`, `max_clicks`,
|
|
32
|
+
`track_conversions`, `routing_rules`, and `ab_variants`. `password` and
|
|
33
|
+
`max_clicks` accept `Rerout::CLEAR` to send `null`; `routing_rules` and
|
|
34
|
+
`ab_variants` are a full replacement.
|
|
35
|
+
- New `Rerout::Models::RoutingRule` and `Rerout::Models::AbVariant` value
|
|
36
|
+
objects. Routing-rule `condition_type` is `"country"` / `"device"`;
|
|
37
|
+
`condition_op` is `"is"` / `"is_not"` / `"in"`.
|
|
38
|
+
- New `conversions` namespace — `client.conversions.record(click_id,
|
|
39
|
+
event_name, value_cents:, currency:)` against `POST /v1/conversions`,
|
|
40
|
+
returning a `Rerout::Models::RecordedConversion` (`recorded`, `duplicate`).
|
|
41
|
+
- New `client.links.create_batch(inputs)` against `POST /v1/links/batch`,
|
|
42
|
+
returning a `Rerout::Models::BatchCreateLinksResult` (`created`, `total`,
|
|
43
|
+
`results` of `BatchLinkResult`).
|
|
44
|
+
- New webhook event type `conversion.recorded`.
|
|
45
|
+
|
|
46
|
+
## [0.3.0] - 2026-06-03
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- Webhook endpoint management via a new `webhooks` namespace — `create`, `list`,
|
|
51
|
+
and `delete` against `/v1/projects/me/webhooks` (API-key auth). The signing
|
|
52
|
+
secret returned by `create` is shown once.
|
|
53
|
+
|
|
7
54
|
## [0.2.0] - 2026-06-02
|
|
8
55
|
|
|
9
56
|
### Added
|
|
@@ -40,5 +87,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
40
87
|
- `Rerout::Error` with stable `code`, `status`, `path`, `timestamp`, `details`
|
|
41
88
|
plus `rate_limited?` and `server_error?` convenience flags.
|
|
42
89
|
|
|
90
|
+
[0.3.0]: https://github.com/ModestNerds-Co/rerout-sdks/releases/tag/ruby/v0.3.0
|
|
43
91
|
[0.2.0]: https://github.com/ModestNerds-Co/rerout-sdks/releases/tag/ruby-v0.2.0
|
|
44
92
|
[0.1.0]: https://github.com/ModestNerds-Co/rerout-sdks/releases/tag/ruby-v0.1.0
|
data/README.md
CHANGED
|
@@ -64,7 +64,8 @@ rerout = Rerout::Client.new(
|
|
|
64
64
|
A blank or missing `api_key` raises `Rerout::Error` with code `missing_api_key`
|
|
65
65
|
before any network call.
|
|
66
66
|
|
|
67
|
-
The client exposes
|
|
67
|
+
The client exposes these namespaces: `links`, `project`, `qr`, `webhooks`,
|
|
68
|
+
`conversions`, and `tags`.
|
|
68
69
|
|
|
69
70
|
## Links
|
|
70
71
|
|
|
@@ -111,6 +112,99 @@ rerout.links.update('q4', Rerout::UpdateLinkInput.new(target_url: 'https://new.e
|
|
|
111
112
|
An `UpdateLinkInput` with no fields set raises `Rerout::Error` (code
|
|
112
113
|
`empty_update`) client-side without hitting the API.
|
|
113
114
|
|
|
115
|
+
## Smart Links
|
|
116
|
+
|
|
117
|
+
Links carry Smart Link configuration: `password_protected`, `max_clicks`,
|
|
118
|
+
`click_count`, `track_conversions`, plus `routing_rules` (array of
|
|
119
|
+
`Rerout::Models::RoutingRule`) and `ab_variants` (array of
|
|
120
|
+
`Rerout::Models::AbVariant`).
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
rerout.links.create(
|
|
124
|
+
Rerout::CreateLinkInput.new(
|
|
125
|
+
target_url: 'https://example.com',
|
|
126
|
+
password: 'hunter2',
|
|
127
|
+
max_clicks: 500,
|
|
128
|
+
track_conversions: true,
|
|
129
|
+
routing_rules: [
|
|
130
|
+
Rerout::Models::RoutingRule.new(
|
|
131
|
+
condition_type: 'country', # "country" | "device"
|
|
132
|
+
condition_op: 'in', # "is" | "is_not" | "in"
|
|
133
|
+
condition_value: 'US,CA',
|
|
134
|
+
target_url: 'https://example.com/na'
|
|
135
|
+
)
|
|
136
|
+
],
|
|
137
|
+
ab_variants: [
|
|
138
|
+
Rerout::Models::AbVariant.new(target_url: 'https://example.com/a', weight: 70),
|
|
139
|
+
Rerout::Models::AbVariant.new(target_url: 'https://example.com/b', weight: 30)
|
|
140
|
+
]
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# On update, password/max_clicks accept Rerout::CLEAR to null them;
|
|
145
|
+
# routing_rules and ab_variants fully replace the existing config.
|
|
146
|
+
rerout.links.update('q4', Rerout::UpdateLinkInput.new(password: Rerout::CLEAR, max_clicks: Rerout::CLEAR))
|
|
147
|
+
rerout.links.update('q4', Rerout::UpdateLinkInput.new(routing_rules: [], ab_variants: []))
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Routing rules and A/B variants also accept plain Hashes.
|
|
151
|
+
|
|
152
|
+
## Batch link creation
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
result = rerout.links.create_batch(
|
|
156
|
+
[
|
|
157
|
+
Rerout::CreateLinkInput.new(target_url: 'https://example.com/1'),
|
|
158
|
+
Rerout::CreateLinkInput.new(target_url: 'https://example.com/2', code: 'promo')
|
|
159
|
+
]
|
|
160
|
+
)
|
|
161
|
+
result.created # => 2
|
|
162
|
+
result.total # => 2
|
|
163
|
+
result.results.each { |r| puts [r.index, r.ok, r.code || r.error].inspect }
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The batch endpoint accepts `target_url`, `code`, `expires_at`, and
|
|
167
|
+
`domain_hostname` per link; other fields are ignored.
|
|
168
|
+
|
|
169
|
+
## Conversions
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
result = rerout.conversions.record('clk_123', 'purchase', value_cents: 4999, currency: 'USD')
|
|
173
|
+
result.recorded # => true
|
|
174
|
+
result.duplicate # => false
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`value_cents` and `currency` are optional. The call is idempotent — a repeat
|
|
178
|
+
for the same click + event returns `duplicate: true`.
|
|
179
|
+
|
|
180
|
+
## Tags
|
|
181
|
+
|
|
182
|
+
Manage the tags that can be attached to links for the project that owns the API
|
|
183
|
+
key. (Links carry their tags read-only as `link.tags`; this namespace lets you
|
|
184
|
+
list, create, update, and delete them.)
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
# List — each tag carries its live (non-deleted) link count.
|
|
188
|
+
result = rerout.tags.list
|
|
189
|
+
result.tags # => [Rerout::Models::TagSummary, ...] — { id, name, color, link_count }
|
|
190
|
+
result.tags.first.link_count # => 4
|
|
191
|
+
|
|
192
|
+
# Create — name is required; color is optional (server defaults to "teal").
|
|
193
|
+
tag = rerout.tags.create(Rerout::CreateTagInput.new(name: 'Spring 2026', color: 'teal'))
|
|
194
|
+
tag # => Rerout::Models::Tag — { id, name, color }
|
|
195
|
+
|
|
196
|
+
# Update — only the fields you set are sent; omitted fields are left unchanged.
|
|
197
|
+
rerout.tags.update(tag.id, Rerout::UpdateTagInput.new(name: 'Renamed'))
|
|
198
|
+
rerout.tags.update(tag.id, Rerout::UpdateTagInput.new(color: 'red'))
|
|
199
|
+
|
|
200
|
+
# Delete — also drops the tag from every link it was attached to.
|
|
201
|
+
rerout.tags.delete(tag.id) # => { "deleted" => true }
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`CreateTagInput` and `UpdateTagInput` also accept plain Hashes. An
|
|
205
|
+
`UpdateTagInput` with no fields set sends an empty body; the server rejects a
|
|
206
|
+
fully empty patch with HTTP `400`.
|
|
207
|
+
|
|
114
208
|
## Project
|
|
115
209
|
|
|
116
210
|
```ruby
|
|
@@ -147,6 +241,38 @@ File.write('q4.svg', svg)
|
|
|
147
241
|
QR options: `size` (1–32), `margin` (0–16), `ecc` (`L`/`M`/`Q`/`H`), `domain`,
|
|
148
242
|
and `refresh` (`true` is serialized as `1`; a string is sent verbatim).
|
|
149
243
|
|
|
244
|
+
## Webhook management
|
|
245
|
+
|
|
246
|
+
Manage the webhook endpoints that receive event deliveries for the project that
|
|
247
|
+
owns the API key. (This is the `webhooks` namespace — distinct from
|
|
248
|
+
`Rerout::Webhooks`, which verifies inbound signatures below.)
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
# Create — name, url, and events are required.
|
|
252
|
+
created = rerout.webhooks.create(
|
|
253
|
+
Rerout::CreateWebhookInput.new(
|
|
254
|
+
name: 'prod listener',
|
|
255
|
+
url: 'https://hooks.brand.com/rerout',
|
|
256
|
+
events: ['link.created'],
|
|
257
|
+
payload_format: 'json' # optional: "json" (default) or "slack"
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
created.endpoint.id # => "wh_..."
|
|
262
|
+
created.signing_secret # => "whsec_..." — shown ONCE; persist it now.
|
|
263
|
+
|
|
264
|
+
# List endpoints plus the event types the server can deliver.
|
|
265
|
+
result = rerout.webhooks.list
|
|
266
|
+
result.endpoints # => [Rerout::Models::Webhook, ...]
|
|
267
|
+
result.event_types # => ["link.created", ...]
|
|
268
|
+
|
|
269
|
+
# Delete (soft delete) — idempotent.
|
|
270
|
+
rerout.webhooks.delete('wh_...') # => { "deleted" => true }
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The `signing_secret` returned by `create` is the value you pass as `secret:` to
|
|
274
|
+
`verify_signature` below — store it securely, as it cannot be retrieved again.
|
|
275
|
+
|
|
150
276
|
## Webhook signature verification
|
|
151
277
|
|
|
152
278
|
Rerout signs every webhook delivery with an `X-Rerout-Signature` header. Verify
|
data/lib/rerout/client.rb
CHANGED
|
@@ -5,14 +5,21 @@ require 'json'
|
|
|
5
5
|
|
|
6
6
|
require_relative 'version'
|
|
7
7
|
require_relative 'error'
|
|
8
|
+
require_relative 'models'
|
|
9
|
+
require_relative 'input_serialization'
|
|
8
10
|
require_relative 'create_link_input'
|
|
9
11
|
require_relative 'update_link_input'
|
|
12
|
+
require_relative 'create_webhook_input'
|
|
13
|
+
require_relative 'create_tag_input'
|
|
14
|
+
require_relative 'update_tag_input'
|
|
10
15
|
require_relative 'qr_options'
|
|
11
16
|
require_relative 'webhooks'
|
|
12
|
-
require_relative 'models'
|
|
13
17
|
require_relative 'links'
|
|
14
18
|
require_relative 'project'
|
|
15
19
|
require_relative 'qr'
|
|
20
|
+
require_relative 'webhooks_resource'
|
|
21
|
+
require_relative 'conversions_resource'
|
|
22
|
+
require_relative 'tags_resource'
|
|
16
23
|
|
|
17
24
|
module Rerout
|
|
18
25
|
# Default production API base URL.
|
|
@@ -36,6 +43,12 @@ module Rerout
|
|
|
36
43
|
attr_reader :project
|
|
37
44
|
# @return [Resources::Qr] QR namespace.
|
|
38
45
|
attr_reader :qr
|
|
46
|
+
# @return [Resources::Webhooks] webhook endpoint management namespace.
|
|
47
|
+
attr_reader :webhooks
|
|
48
|
+
# @return [Resources::Conversions] conversion tracking namespace.
|
|
49
|
+
attr_reader :conversions
|
|
50
|
+
# @return [Resources::Tags] tag management namespace.
|
|
51
|
+
attr_reader :tags
|
|
39
52
|
|
|
40
53
|
# @param api_key [String] project API key (`rrk_…`). Required.
|
|
41
54
|
# @param base_url [String, nil] override base URL. Defaults to `https://api.rerout.co`.
|
|
@@ -61,6 +74,9 @@ module Rerout
|
|
|
61
74
|
@links = Resources::Links.new(self)
|
|
62
75
|
@project = Resources::Project.new(self)
|
|
63
76
|
@qr = Resources::Qr.new(self)
|
|
77
|
+
@webhooks = Resources::Webhooks.new(self)
|
|
78
|
+
@conversions = Resources::Conversions.new(self)
|
|
79
|
+
@tags = Resources::Tags.new(self)
|
|
64
80
|
end
|
|
65
81
|
|
|
66
82
|
# Perform a JSON request against the Rerout API.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rerout
|
|
4
|
+
module Resources
|
|
5
|
+
# Conversion tracking namespace — record a conversion against a recorded
|
|
6
|
+
# click. Reach it via `client.conversions`.
|
|
7
|
+
class Conversions
|
|
8
|
+
# @param client [Rerout::Client]
|
|
9
|
+
def initialize(client)
|
|
10
|
+
@client = client
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Record a conversion for a click via `POST /v1/conversions`.
|
|
14
|
+
#
|
|
15
|
+
# @param click_id [String] the id of the click being converted.
|
|
16
|
+
# @param event_name [String] name of the conversion event (e.g. `"purchase"`).
|
|
17
|
+
# @param value_cents [Integer, nil] optional monetary value in the
|
|
18
|
+
# smallest currency unit (cents). Omitted when nil.
|
|
19
|
+
# @param currency [String, nil] optional ISO-4217 currency code
|
|
20
|
+
# (e.g. `"USD"`). Omitted when nil.
|
|
21
|
+
# @return [Rerout::Models::RecordedConversion] `recorded` + `duplicate`
|
|
22
|
+
# flags. Idempotent — a repeat for the same click + event returns
|
|
23
|
+
# `duplicate: true`.
|
|
24
|
+
def record(click_id, event_name, value_cents: nil, currency: nil)
|
|
25
|
+
raise ArgumentError, 'click_id is required' if click_id.nil? || click_id.to_s.empty?
|
|
26
|
+
raise ArgumentError, 'event_name is required' if event_name.nil? || event_name.to_s.empty?
|
|
27
|
+
|
|
28
|
+
body = { 'click_id' => click_id, 'event_name' => event_name }
|
|
29
|
+
body['value_cents'] = value_cents unless value_cents.nil?
|
|
30
|
+
body['currency'] = currency unless currency.nil?
|
|
31
|
+
|
|
32
|
+
response = @client.request(method: :post, path: '/v1/conversions', body: body)
|
|
33
|
+
Models::RecordedConversion.from_hash(response)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -6,7 +6,9 @@ module Rerout
|
|
|
6
6
|
class CreateLinkInput
|
|
7
7
|
attr_reader :target_url, :domain_hostname, :code, :expires_at,
|
|
8
8
|
:seo_title, :seo_description, :seo_image_url,
|
|
9
|
-
:seo_canonical_url, :seo_noindex
|
|
9
|
+
:seo_canonical_url, :seo_noindex,
|
|
10
|
+
:password, :max_clicks, :track_conversions,
|
|
11
|
+
:routing_rules, :ab_variants
|
|
10
12
|
|
|
11
13
|
# @param target_url [String] required, the destination URL.
|
|
12
14
|
# @param domain_hostname [String, nil] verified custom domain (e.g. `go.brand.com`).
|
|
@@ -17,9 +19,18 @@ module Rerout
|
|
|
17
19
|
# @param seo_image_url [String, nil] absolute https:// URL.
|
|
18
20
|
# @param seo_canonical_url [String, nil]
|
|
19
21
|
# @param seo_noindex [Boolean, nil] default server-side: `true`.
|
|
22
|
+
# @param password [String, nil] gate the link behind a password.
|
|
23
|
+
# @param max_clicks [Integer, nil] disable the link after this many clicks.
|
|
24
|
+
# @param track_conversions [Boolean, nil] enable conversion tracking.
|
|
25
|
+
# @param routing_rules [Array<Rerout::Models::RoutingRule, Hash>, nil]
|
|
26
|
+
# Smart Link routing rules.
|
|
27
|
+
# @param ab_variants [Array<Rerout::Models::AbVariant, Hash>, nil]
|
|
28
|
+
# A/B-test variants — each `{ target_url:, weight? }`.
|
|
20
29
|
def initialize(target_url:, domain_hostname: nil, code: nil, expires_at: nil,
|
|
21
30
|
seo_title: nil, seo_description: nil, seo_image_url: nil,
|
|
22
|
-
seo_canonical_url: nil, seo_noindex: nil
|
|
31
|
+
seo_canonical_url: nil, seo_noindex: nil,
|
|
32
|
+
password: nil, max_clicks: nil, track_conversions: nil,
|
|
33
|
+
routing_rules: nil, ab_variants: nil)
|
|
23
34
|
raise ArgumentError, 'target_url is required' if target_url.nil? || target_url.to_s.empty?
|
|
24
35
|
|
|
25
36
|
@target_url = target_url
|
|
@@ -31,6 +42,11 @@ module Rerout
|
|
|
31
42
|
@seo_image_url = seo_image_url
|
|
32
43
|
@seo_canonical_url = seo_canonical_url
|
|
33
44
|
@seo_noindex = seo_noindex
|
|
45
|
+
@password = password
|
|
46
|
+
@max_clicks = max_clicks
|
|
47
|
+
@track_conversions = track_conversions
|
|
48
|
+
@routing_rules = routing_rules
|
|
49
|
+
@ab_variants = ab_variants
|
|
34
50
|
freeze
|
|
35
51
|
end
|
|
36
52
|
|
|
@@ -45,6 +61,11 @@ module Rerout
|
|
|
45
61
|
hash['seo_image_url'] = seo_image_url unless seo_image_url.nil?
|
|
46
62
|
hash['seo_canonical_url'] = seo_canonical_url unless seo_canonical_url.nil?
|
|
47
63
|
hash['seo_noindex'] = seo_noindex unless seo_noindex.nil?
|
|
64
|
+
hash['password'] = password unless password.nil?
|
|
65
|
+
hash['max_clicks'] = max_clicks unless max_clicks.nil?
|
|
66
|
+
hash['track_conversions'] = track_conversions unless track_conversions.nil?
|
|
67
|
+
hash['routing_rules'] = routing_rules.map { |r| InputSerialization.rule_hash(r) } unless routing_rules.nil?
|
|
68
|
+
hash['ab_variants'] = ab_variants.map { |v| InputSerialization.variant_hash(v) } unless ab_variants.nil?
|
|
48
69
|
hash
|
|
49
70
|
end
|
|
50
71
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rerout
|
|
4
|
+
# Request body for `POST /v1/projects/me/tags`. `name` is required; `color`
|
|
5
|
+
# is optional and omitted from the payload when not set (the server validates
|
|
6
|
+
# it against its palette and defaults to `"teal"`).
|
|
7
|
+
class CreateTagInput
|
|
8
|
+
attr_reader :name, :color
|
|
9
|
+
|
|
10
|
+
# @param name [String] required, the tag label.
|
|
11
|
+
# @param color [String, nil] optional tag color. Server default: `"teal"`.
|
|
12
|
+
def initialize(name:, color: nil)
|
|
13
|
+
raise ArgumentError, 'name is required' if name.nil? || name.to_s.empty?
|
|
14
|
+
|
|
15
|
+
@name = name
|
|
16
|
+
@color = color
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Serialize for the wire. `color` is only included when set.
|
|
21
|
+
def to_h
|
|
22
|
+
hash = { 'name' => name }
|
|
23
|
+
hash['color'] = color unless color.nil?
|
|
24
|
+
hash
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rerout
|
|
4
|
+
# Request body for `POST /v1/projects/me/webhooks`. `name`, `url`, and
|
|
5
|
+
# `events` are required; `is_active` and `payload_format` are optional and
|
|
6
|
+
# omitted from the payload when not set (server defaults apply).
|
|
7
|
+
class CreateWebhookInput
|
|
8
|
+
attr_reader :name, :url, :events, :is_active, :payload_format
|
|
9
|
+
|
|
10
|
+
# @param name [String] required, human-readable label for the endpoint.
|
|
11
|
+
# @param url [String] required, public https:// URL that receives deliveries.
|
|
12
|
+
# @param events [Array<String>] required, non-empty list of event types
|
|
13
|
+
# to subscribe to (e.g. `link.created`).
|
|
14
|
+
# @param is_active [Boolean, nil] whether the endpoint starts active.
|
|
15
|
+
# Server default: `true`.
|
|
16
|
+
# @param payload_format [String, nil] payload encoding — `"json"` or
|
|
17
|
+
# `"slack"`. Server default: `"json"`.
|
|
18
|
+
def initialize(name:, url:, events:, is_active: nil, payload_format: nil)
|
|
19
|
+
raise ArgumentError, 'name is required' if name.nil? || name.to_s.empty?
|
|
20
|
+
raise ArgumentError, 'url is required' if url.nil? || url.to_s.empty?
|
|
21
|
+
if events.nil? || !events.is_a?(Array) || events.empty?
|
|
22
|
+
raise ArgumentError, 'events is required and must be a non-empty Array'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
@name = name
|
|
26
|
+
@url = url
|
|
27
|
+
@events = events
|
|
28
|
+
@is_active = is_active
|
|
29
|
+
@payload_format = payload_format
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Serialize for the wire. Optional fields are only included when set.
|
|
34
|
+
def to_h
|
|
35
|
+
hash = { 'name' => name, 'url' => url, 'events' => events }
|
|
36
|
+
hash['is_active'] = is_active unless is_active.nil?
|
|
37
|
+
hash['payload_format'] = payload_format unless payload_format.nil?
|
|
38
|
+
hash
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rerout
|
|
4
|
+
# Internal helpers for serializing Smart Link sub-objects (routing rules and
|
|
5
|
+
# A/B variants) accepted by {CreateLinkInput} / {UpdateLinkInput}. Each input
|
|
6
|
+
# element may be a {Rerout::Models::RoutingRule} / {Rerout::Models::AbVariant}
|
|
7
|
+
# value object or a plain Hash; both are coerced to the wire shape.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module InputSerialization
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @param rule [Rerout::Models::RoutingRule, Hash]
|
|
14
|
+
# @return [Hash] string-keyed routing-rule payload.
|
|
15
|
+
def rule_hash(rule)
|
|
16
|
+
return rule.to_h if rule.is_a?(Models::RoutingRule)
|
|
17
|
+
return stringify(rule) if rule.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
raise ArgumentError, 'routing_rules entries must be Rerout::Models::RoutingRule or Hash'
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param variant [Rerout::Models::AbVariant, Hash]
|
|
23
|
+
# @return [Hash] string-keyed A/B-variant payload (no server `id`).
|
|
24
|
+
def variant_hash(variant)
|
|
25
|
+
return variant.to_h if variant.is_a?(Models::AbVariant)
|
|
26
|
+
raise ArgumentError, 'ab_variants entries must be Rerout::Models::AbVariant or Hash' unless variant.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
h = stringify(variant)
|
|
29
|
+
h.delete('id')
|
|
30
|
+
h['weight'] = 1 unless h.key?('weight')
|
|
31
|
+
h
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Normalize a Hash to string keys without mutating the caller's object.
|
|
35
|
+
def stringify(hash)
|
|
36
|
+
hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/rerout/links.rb
CHANGED
|
@@ -21,6 +21,30 @@ module Rerout
|
|
|
21
21
|
Models::Link.from_hash(response)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# Create many links in one call via `POST /v1/links/batch`.
|
|
25
|
+
#
|
|
26
|
+
# Each item may be a {Rerout::CreateLinkInput} or a plain Hash. Only the
|
|
27
|
+
# batch-supported fields (`target_url`, `code`, `expires_at`,
|
|
28
|
+
# `domain_hostname`) are forwarded — richer Smart Link fields are not
|
|
29
|
+
# accepted by the batch endpoint. A failed item does not raise; inspect
|
|
30
|
+
# `result.results[i].ok`.
|
|
31
|
+
#
|
|
32
|
+
# @param inputs [Array<Rerout::CreateLinkInput, Hash>]
|
|
33
|
+
# @return [Rerout::Models::BatchCreateLinksResult]
|
|
34
|
+
def create_batch(inputs)
|
|
35
|
+
links = Array(inputs).map { |item| batch_link_hash(item) }
|
|
36
|
+
if links.empty?
|
|
37
|
+
raise Error.new(
|
|
38
|
+
code: 'bad_request',
|
|
39
|
+
message: 'create_batch requires at least one link.',
|
|
40
|
+
status: 0
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
response = @client.request(method: :post, path: '/v1/links/batch', body: { 'links' => links })
|
|
45
|
+
Models::BatchCreateLinksResult.from_hash(response)
|
|
46
|
+
end
|
|
47
|
+
|
|
24
48
|
# Paginated list of links.
|
|
25
49
|
#
|
|
26
50
|
# @param cursor [Integer, nil]
|
|
@@ -100,6 +124,28 @@ module Rerout
|
|
|
100
124
|
raise ArgumentError, 'input must be a Rerout::CreateLinkInput or Hash'
|
|
101
125
|
end
|
|
102
126
|
end
|
|
127
|
+
|
|
128
|
+
# Reduce a create-link input to the fields the batch endpoint accepts:
|
|
129
|
+
# `target_url` (required), `code`, `expires_at`, `domain_hostname`.
|
|
130
|
+
def batch_link_hash(item)
|
|
131
|
+
source =
|
|
132
|
+
case item
|
|
133
|
+
when CreateLinkInput then item.to_h
|
|
134
|
+
when Hash then InputSerialization.stringify(item)
|
|
135
|
+
else
|
|
136
|
+
raise ArgumentError, 'batch link items must be a Rerout::CreateLinkInput or Hash'
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
unless source.key?('target_url') && !source['target_url'].to_s.empty?
|
|
140
|
+
raise ArgumentError, 'each batch link requires a target_url'
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
out = { 'target_url' => source['target_url'] }
|
|
144
|
+
%w[code expires_at domain_hostname].each do |key|
|
|
145
|
+
out[key] = source[key] unless source[key].nil?
|
|
146
|
+
end
|
|
147
|
+
out
|
|
148
|
+
end
|
|
103
149
|
end
|
|
104
150
|
end
|
|
105
151
|
end
|
data/lib/rerout/models.rb
CHANGED
|
@@ -35,12 +35,158 @@ module Rerout
|
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# A tag with the number of live (non-deleted) links it is attached to —
|
|
39
|
+
# `{ id:, name:, color:, link_count: }`. Returned by `tags.list`; the
|
|
40
|
+
# create/update responses use the plain {Tag} (no `link_count`).
|
|
41
|
+
class TagSummary
|
|
42
|
+
attr_reader :id, :name, :color, :link_count
|
|
43
|
+
|
|
44
|
+
def initialize(id:, name:, color:, link_count:)
|
|
45
|
+
@id = id
|
|
46
|
+
@name = name
|
|
47
|
+
@color = color
|
|
48
|
+
@link_count = link_count
|
|
49
|
+
freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.from_hash(hash)
|
|
53
|
+
new(
|
|
54
|
+
id: hash['id'],
|
|
55
|
+
name: hash['name'],
|
|
56
|
+
color: hash['color'],
|
|
57
|
+
link_count: hash.fetch('link_count', 0)
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_h
|
|
62
|
+
{ id: id, name: name, color: color, link_count: link_count }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ==(other)
|
|
66
|
+
other.is_a?(TagSummary) && other.to_h == to_h
|
|
67
|
+
end
|
|
68
|
+
alias eql? ==
|
|
69
|
+
|
|
70
|
+
def hash
|
|
71
|
+
[self.class, id, name, color, link_count].hash
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Result of `tags.list` — the project's tags with their live link counts.
|
|
76
|
+
class ListTagsResult
|
|
77
|
+
attr_reader :tags
|
|
78
|
+
|
|
79
|
+
def initialize(tags:)
|
|
80
|
+
@tags = tags.freeze
|
|
81
|
+
freeze
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.from_hash(hash)
|
|
85
|
+
new(tags: (hash['tags'] || []).map { |t| TagSummary.from_hash(t) })
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def to_h
|
|
89
|
+
{ tags: tags.map(&:to_h) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ==(other)
|
|
93
|
+
other.is_a?(ListTagsResult) && other.to_h == to_h
|
|
94
|
+
end
|
|
95
|
+
alias eql? ==
|
|
96
|
+
|
|
97
|
+
def hash
|
|
98
|
+
to_h.hash
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A Smart Link routing rule — send matching visitors to `target_url`.
|
|
103
|
+
# `condition_type` is `"country"` or `"device"`; `condition_op` is `"is"`,
|
|
104
|
+
# `"is_not"`, or `"in"`; `condition_value` is the value to compare against
|
|
105
|
+
# (e.g. `"US"` or `"US,CA,GB"` for `"in"`).
|
|
106
|
+
class RoutingRule
|
|
107
|
+
attr_reader :condition_type, :condition_op, :condition_value, :target_url
|
|
108
|
+
|
|
109
|
+
def initialize(condition_type:, condition_op:, condition_value:, target_url:)
|
|
110
|
+
@condition_type = condition_type
|
|
111
|
+
@condition_op = condition_op
|
|
112
|
+
@condition_value = condition_value
|
|
113
|
+
@target_url = target_url
|
|
114
|
+
freeze
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.from_hash(hash)
|
|
118
|
+
new(
|
|
119
|
+
condition_type: hash['condition_type'],
|
|
120
|
+
condition_op: hash['condition_op'],
|
|
121
|
+
condition_value: hash['condition_value'],
|
|
122
|
+
target_url: hash['target_url']
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def to_h
|
|
127
|
+
{
|
|
128
|
+
'condition_type' => condition_type,
|
|
129
|
+
'condition_op' => condition_op,
|
|
130
|
+
'condition_value' => condition_value,
|
|
131
|
+
'target_url' => target_url
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def ==(other)
|
|
136
|
+
other.is_a?(RoutingRule) && other.to_h == to_h
|
|
137
|
+
end
|
|
138
|
+
alias eql? ==
|
|
139
|
+
|
|
140
|
+
def hash
|
|
141
|
+
to_h.hash
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# One A/B-test variant attached to a Smart Link. `id` is assigned
|
|
146
|
+
# server-side (read-only); `weight` controls the relative traffic share and
|
|
147
|
+
# defaults to `1`.
|
|
148
|
+
class AbVariant
|
|
149
|
+
attr_reader :id, :target_url, :weight
|
|
150
|
+
|
|
151
|
+
def initialize(target_url:, weight: 1, id: nil)
|
|
152
|
+
@id = id
|
|
153
|
+
@target_url = target_url
|
|
154
|
+
@weight = weight
|
|
155
|
+
freeze
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.from_hash(hash)
|
|
159
|
+
new(
|
|
160
|
+
id: hash['id'],
|
|
161
|
+
target_url: hash['target_url'],
|
|
162
|
+
weight: hash.fetch('weight', 1)
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Render for create/update. The server-assigned `id` is never sent.
|
|
167
|
+
def to_h
|
|
168
|
+
{ 'target_url' => target_url, 'weight' => weight }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def ==(other)
|
|
172
|
+
other.is_a?(AbVariant) && other.id == id &&
|
|
173
|
+
other.target_url == target_url && other.weight == weight
|
|
174
|
+
end
|
|
175
|
+
alias eql? ==
|
|
176
|
+
|
|
177
|
+
def hash
|
|
178
|
+
[self.class, id, target_url, weight].hash
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
38
182
|
# A short link.
|
|
39
183
|
class Link
|
|
40
184
|
ATTRS = %i[
|
|
41
185
|
code short_url domain_hostname target_url project_id expires_at
|
|
42
186
|
is_active seo_title seo_description seo_image_url seo_canonical_url
|
|
43
187
|
seo_noindex seo_updated_at tags created_at updated_at
|
|
188
|
+
password_protected max_clicks click_count track_conversions
|
|
189
|
+
routing_rules ab_variants
|
|
44
190
|
].freeze
|
|
45
191
|
|
|
46
192
|
attr_reader(*ATTRS)
|
|
@@ -48,6 +194,8 @@ module Rerout
|
|
|
48
194
|
def initialize(**attrs)
|
|
49
195
|
ATTRS.each { |k| instance_variable_set(:"@#{k}", attrs[k]) }
|
|
50
196
|
@tags = (@tags || []).freeze
|
|
197
|
+
@routing_rules = (@routing_rules || []).freeze
|
|
198
|
+
@ab_variants = (@ab_variants || []).freeze
|
|
51
199
|
freeze
|
|
52
200
|
end
|
|
53
201
|
|
|
@@ -68,13 +216,24 @@ module Rerout
|
|
|
68
216
|
seo_updated_at: hash['seo_updated_at'],
|
|
69
217
|
tags: (hash['tags'] || []).map { |t| Tag.from_hash(t) },
|
|
70
218
|
created_at: hash['created_at'],
|
|
71
|
-
updated_at: hash['updated_at']
|
|
219
|
+
updated_at: hash['updated_at'],
|
|
220
|
+
password_protected: hash.fetch('password_protected', false),
|
|
221
|
+
max_clicks: hash['max_clicks'],
|
|
222
|
+
click_count: hash.fetch('click_count', 0),
|
|
223
|
+
track_conversions: hash.fetch('track_conversions', false),
|
|
224
|
+
routing_rules: (hash['routing_rules'] || []).map { |r| RoutingRule.from_hash(r) },
|
|
225
|
+
ab_variants: (hash['ab_variants'] || []).map { |v| AbVariant.from_hash(v) }
|
|
72
226
|
)
|
|
73
227
|
end
|
|
74
228
|
|
|
75
229
|
def to_h
|
|
76
230
|
ATTRS.to_h do |k|
|
|
77
|
-
[k,
|
|
231
|
+
[k, case k
|
|
232
|
+
when :tags then tags.map(&:to_h)
|
|
233
|
+
when :routing_rules then routing_rules.map(&:to_h)
|
|
234
|
+
when :ab_variants then ab_variants.map(&:to_h)
|
|
235
|
+
else public_send(k)
|
|
236
|
+
end]
|
|
78
237
|
end
|
|
79
238
|
end
|
|
80
239
|
|
|
@@ -301,5 +460,218 @@ module Rerout
|
|
|
301
460
|
[self.class, id, name, slug].hash
|
|
302
461
|
end
|
|
303
462
|
end
|
|
463
|
+
|
|
464
|
+
# A webhook endpoint registered to the project. Mirrors the server-side
|
|
465
|
+
# `WebhookEndpointResponse`.
|
|
466
|
+
class Webhook
|
|
467
|
+
ATTRS = %i[
|
|
468
|
+
id project_id name url events is_active payload_format
|
|
469
|
+
created_at updated_at last_delivery_at last_success_at last_failure_at
|
|
470
|
+
].freeze
|
|
471
|
+
|
|
472
|
+
attr_reader(*ATTRS)
|
|
473
|
+
|
|
474
|
+
def initialize(**attrs)
|
|
475
|
+
ATTRS.each { |k| instance_variable_set(:"@#{k}", attrs[k]) }
|
|
476
|
+
@events = (@events || []).freeze
|
|
477
|
+
freeze
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def self.from_hash(hash)
|
|
481
|
+
new(
|
|
482
|
+
id: hash['id'],
|
|
483
|
+
project_id: hash['project_id'],
|
|
484
|
+
name: hash['name'],
|
|
485
|
+
url: hash['url'],
|
|
486
|
+
events: hash['events'] || [],
|
|
487
|
+
is_active: hash['is_active'],
|
|
488
|
+
payload_format: hash['payload_format'],
|
|
489
|
+
created_at: hash['created_at'],
|
|
490
|
+
updated_at: hash['updated_at'],
|
|
491
|
+
last_delivery_at: hash['last_delivery_at'],
|
|
492
|
+
last_success_at: hash['last_success_at'],
|
|
493
|
+
last_failure_at: hash['last_failure_at']
|
|
494
|
+
)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def to_h
|
|
498
|
+
ATTRS.to_h { |k| [k, public_send(k)] }
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def ==(other)
|
|
502
|
+
other.is_a?(Webhook) && other.to_h == to_h
|
|
503
|
+
end
|
|
504
|
+
alias eql? ==
|
|
505
|
+
|
|
506
|
+
def hash
|
|
507
|
+
to_h.hash
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Result of creating a webhook. The `signing_secret` (`whsec_…`) is
|
|
512
|
+
# returned **once** — store it now; it is never shown again.
|
|
513
|
+
class CreatedWebhook
|
|
514
|
+
attr_reader :endpoint, :signing_secret
|
|
515
|
+
|
|
516
|
+
def initialize(endpoint:, signing_secret:)
|
|
517
|
+
@endpoint = endpoint
|
|
518
|
+
@signing_secret = signing_secret
|
|
519
|
+
freeze
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def self.from_hash(hash)
|
|
523
|
+
new(
|
|
524
|
+
endpoint: Webhook.from_hash(hash['endpoint'] || {}),
|
|
525
|
+
signing_secret: hash['signing_secret']
|
|
526
|
+
)
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def to_h
|
|
530
|
+
{ endpoint: endpoint.to_h, signing_secret: signing_secret }
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def ==(other)
|
|
534
|
+
other.is_a?(CreatedWebhook) && other.to_h == to_h
|
|
535
|
+
end
|
|
536
|
+
alias eql? ==
|
|
537
|
+
|
|
538
|
+
def hash
|
|
539
|
+
to_h.hash
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# List of webhook endpoints plus every event type the server can deliver.
|
|
544
|
+
class ListWebhooksResult
|
|
545
|
+
attr_reader :endpoints, :event_types
|
|
546
|
+
|
|
547
|
+
def initialize(endpoints:, event_types:)
|
|
548
|
+
@endpoints = endpoints.freeze
|
|
549
|
+
@event_types = event_types.freeze
|
|
550
|
+
freeze
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def self.from_hash(hash)
|
|
554
|
+
new(
|
|
555
|
+
endpoints: (hash['endpoints'] || []).map { |e| Webhook.from_hash(e) },
|
|
556
|
+
event_types: hash['event_types'] || []
|
|
557
|
+
)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def to_h
|
|
561
|
+
{ endpoints: endpoints.map(&:to_h), event_types: event_types }
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def ==(other)
|
|
565
|
+
other.is_a?(ListWebhooksResult) && other.to_h == to_h
|
|
566
|
+
end
|
|
567
|
+
alias eql? ==
|
|
568
|
+
|
|
569
|
+
def hash
|
|
570
|
+
to_h.hash
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Result of recording a conversion via `POST /v1/conversions`. `recorded`
|
|
575
|
+
# is true when stored; `duplicate` is true when an identical conversion for
|
|
576
|
+
# the same click had already been recorded (the call is idempotent).
|
|
577
|
+
class RecordedConversion
|
|
578
|
+
attr_reader :recorded, :duplicate
|
|
579
|
+
|
|
580
|
+
def initialize(recorded:, duplicate:)
|
|
581
|
+
@recorded = recorded
|
|
582
|
+
@duplicate = duplicate
|
|
583
|
+
freeze
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def self.from_hash(hash)
|
|
587
|
+
new(
|
|
588
|
+
recorded: hash.fetch('recorded', false),
|
|
589
|
+
duplicate: hash.fetch('duplicate', false)
|
|
590
|
+
)
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def to_h
|
|
594
|
+
{ recorded: recorded, duplicate: duplicate }
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def ==(other)
|
|
598
|
+
other.is_a?(RecordedConversion) && other.recorded == recorded && other.duplicate == duplicate
|
|
599
|
+
end
|
|
600
|
+
alias eql? ==
|
|
601
|
+
|
|
602
|
+
def hash
|
|
603
|
+
[self.class, recorded, duplicate].hash
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# Outcome of one link in a `links.create_batch` call. `index` is the input
|
|
608
|
+
# position; `ok` is true on success (then `code` is set), false on failure
|
|
609
|
+
# (then `error` carries the reason).
|
|
610
|
+
class BatchLinkResult
|
|
611
|
+
attr_reader :index, :ok, :code, :error
|
|
612
|
+
|
|
613
|
+
def initialize(index:, ok:, code: nil, error: nil)
|
|
614
|
+
@index = index
|
|
615
|
+
@ok = ok
|
|
616
|
+
@code = code
|
|
617
|
+
@error = error
|
|
618
|
+
freeze
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def self.from_hash(hash)
|
|
622
|
+
new(
|
|
623
|
+
index: hash['index'],
|
|
624
|
+
ok: hash['ok'],
|
|
625
|
+
code: hash['code'],
|
|
626
|
+
error: hash['error']
|
|
627
|
+
)
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def to_h
|
|
631
|
+
{ index: index, ok: ok, code: code, error: error }
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def ==(other)
|
|
635
|
+
other.is_a?(BatchLinkResult) && other.to_h == to_h
|
|
636
|
+
end
|
|
637
|
+
alias eql? ==
|
|
638
|
+
|
|
639
|
+
def hash
|
|
640
|
+
to_h.hash
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# Aggregate result of `links.create_batch` (`POST /v1/links/batch`).
|
|
645
|
+
class BatchCreateLinksResult
|
|
646
|
+
attr_reader :created, :total, :results
|
|
647
|
+
|
|
648
|
+
def initialize(created:, total:, results:)
|
|
649
|
+
@created = created
|
|
650
|
+
@total = total
|
|
651
|
+
@results = results.freeze
|
|
652
|
+
freeze
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def self.from_hash(hash)
|
|
656
|
+
new(
|
|
657
|
+
created: hash.fetch('created', 0),
|
|
658
|
+
total: hash.fetch('total', 0),
|
|
659
|
+
results: (hash['results'] || []).map { |r| BatchLinkResult.from_hash(r) }
|
|
660
|
+
)
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def to_h
|
|
664
|
+
{ created: created, total: total, results: results.map(&:to_h) }
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def ==(other)
|
|
668
|
+
other.is_a?(BatchCreateLinksResult) && other.to_h == to_h
|
|
669
|
+
end
|
|
670
|
+
alias eql? ==
|
|
671
|
+
|
|
672
|
+
def hash
|
|
673
|
+
to_h.hash
|
|
674
|
+
end
|
|
675
|
+
end
|
|
304
676
|
end
|
|
305
677
|
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
|
|
5
|
+
module Rerout
|
|
6
|
+
module Resources
|
|
7
|
+
# Tag management namespace — list, create, update, and delete the tags that
|
|
8
|
+
# can be attached to links for the project that owns the API key. Reach it
|
|
9
|
+
# via `client.tags`.
|
|
10
|
+
class Tags
|
|
11
|
+
# @param client [Rerout::Client]
|
|
12
|
+
def initialize(client)
|
|
13
|
+
@client = client
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# List the project's tags with their live link counts.
|
|
17
|
+
#
|
|
18
|
+
# @return [Rerout::Models::ListTagsResult]
|
|
19
|
+
def list
|
|
20
|
+
response = @client.request(method: :get, path: '/v1/projects/me/tags')
|
|
21
|
+
Models::ListTagsResult.from_hash(response)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Create a tag. `color` is optional; the server validates and defaults it.
|
|
25
|
+
#
|
|
26
|
+
# @param input [Rerout::CreateTagInput, Hash] the request body.
|
|
27
|
+
# @return [Rerout::Models::Tag] the created tag (no `link_count`).
|
|
28
|
+
def create(input)
|
|
29
|
+
body = coerce_create_input(input)
|
|
30
|
+
response = @client.request(method: :post, path: '/v1/projects/me/tags', body: body)
|
|
31
|
+
Models::Tag.from_hash(response)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Update a tag's name and/or color. Mirrors `links.update`: omitted fields
|
|
35
|
+
# are left unchanged. There is no client-side empty-payload check — the
|
|
36
|
+
# server rejects a fully empty patch with `400`.
|
|
37
|
+
#
|
|
38
|
+
# @param tag_id [String] the tag id (`tag_…`).
|
|
39
|
+
# @param input [Rerout::UpdateTagInput, Hash] the patch body.
|
|
40
|
+
# @return [Rerout::Models::Tag] the updated tag.
|
|
41
|
+
def update(tag_id, input)
|
|
42
|
+
body = coerce_update_input(input)
|
|
43
|
+
response = @client.request(method: :patch, path: tag_path(tag_id), body: body)
|
|
44
|
+
Models::Tag.from_hash(response)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Delete a tag and drop its assignments from all links. Idempotent.
|
|
48
|
+
#
|
|
49
|
+
# @param tag_id [String] the tag id (`tag_…`).
|
|
50
|
+
# @return [Hash] `{ "deleted" => true }`
|
|
51
|
+
def delete(tag_id)
|
|
52
|
+
@client.request(method: :delete, path: tag_path(tag_id))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def tag_path(tag_id)
|
|
58
|
+
raise ArgumentError, 'tag_id is required' if tag_id.nil? || tag_id.to_s.empty?
|
|
59
|
+
|
|
60
|
+
"/v1/projects/me/tags/#{ERB::Util.url_encode(tag_id.to_s)}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def coerce_create_input(input)
|
|
64
|
+
case input
|
|
65
|
+
when CreateTagInput then input.to_h
|
|
66
|
+
when Hash then input
|
|
67
|
+
else
|
|
68
|
+
raise ArgumentError, 'input must be a Rerout::CreateTagInput or Hash'
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def coerce_update_input(input)
|
|
73
|
+
case input
|
|
74
|
+
when UpdateTagInput then input.to_h
|
|
75
|
+
when Hash then input
|
|
76
|
+
else
|
|
77
|
+
raise ArgumentError, 'input must be a Rerout::UpdateTagInput or Hash'
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -37,11 +37,18 @@ module Rerout
|
|
|
37
37
|
FIELDS = %i[
|
|
38
38
|
target_url expires_at is_active seo_title seo_description
|
|
39
39
|
seo_image_url seo_canonical_url seo_noindex
|
|
40
|
+
password max_clicks track_conversions routing_rules ab_variants
|
|
40
41
|
].freeze
|
|
41
42
|
|
|
43
|
+
# Smart Link fields that are a full replacement (an Array of value objects
|
|
44
|
+
# or Hashes) rather than a scalar — serialized element-by-element.
|
|
45
|
+
LIST_FIELDS = %i[routing_rules ab_variants].freeze
|
|
46
|
+
|
|
42
47
|
def initialize(target_url: OMIT, expires_at: OMIT, is_active: OMIT,
|
|
43
48
|
seo_title: OMIT, seo_description: OMIT, seo_image_url: OMIT,
|
|
44
|
-
seo_canonical_url: OMIT, seo_noindex: OMIT
|
|
49
|
+
seo_canonical_url: OMIT, seo_noindex: OMIT,
|
|
50
|
+
password: OMIT, max_clicks: OMIT, track_conversions: OMIT,
|
|
51
|
+
routing_rules: OMIT, ab_variants: OMIT)
|
|
45
52
|
@values = {
|
|
46
53
|
target_url: target_url,
|
|
47
54
|
expires_at: expires_at,
|
|
@@ -50,20 +57,26 @@ module Rerout
|
|
|
50
57
|
seo_description: seo_description,
|
|
51
58
|
seo_image_url: seo_image_url,
|
|
52
59
|
seo_canonical_url: seo_canonical_url,
|
|
53
|
-
seo_noindex: seo_noindex
|
|
60
|
+
seo_noindex: seo_noindex,
|
|
61
|
+
password: password,
|
|
62
|
+
max_clicks: max_clicks,
|
|
63
|
+
track_conversions: track_conversions,
|
|
64
|
+
routing_rules: routing_rules,
|
|
65
|
+
ab_variants: ab_variants
|
|
54
66
|
}
|
|
55
67
|
freeze
|
|
56
68
|
end
|
|
57
69
|
|
|
58
70
|
# Serialize for the wire. Unset fields are omitted; `Rerout::CLEAR`
|
|
59
|
-
# becomes `null`.
|
|
71
|
+
# becomes `null`. `routing_rules` / `ab_variants` are a full replacement
|
|
72
|
+
# and serialize their elements (value objects or Hashes).
|
|
60
73
|
def to_h
|
|
61
74
|
out = {}
|
|
62
75
|
FIELDS.each do |field|
|
|
63
76
|
v = @values[field]
|
|
64
77
|
next if v.equal?(OMIT)
|
|
65
78
|
|
|
66
|
-
out[field.to_s] =
|
|
79
|
+
out[field.to_s] = serialize_field(field, v)
|
|
67
80
|
end
|
|
68
81
|
out
|
|
69
82
|
end
|
|
@@ -77,5 +90,17 @@ module Rerout
|
|
|
77
90
|
def value_for(field)
|
|
78
91
|
@values.fetch(field)
|
|
79
92
|
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def serialize_field(field, value)
|
|
97
|
+
return nil if value.equal?(CLEAR)
|
|
98
|
+
|
|
99
|
+
case field
|
|
100
|
+
when :routing_rules then value.map { |r| InputSerialization.rule_hash(r) }
|
|
101
|
+
when :ab_variants then value.map { |v| InputSerialization.variant_hash(v) }
|
|
102
|
+
else value
|
|
103
|
+
end
|
|
104
|
+
end
|
|
80
105
|
end
|
|
81
106
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rerout
|
|
4
|
+
# Request body for `PATCH /v1/projects/me/tags/:tag_id`. Both fields are
|
|
5
|
+
# optional; an omitted field is left unchanged. Unlike {UpdateLinkInput},
|
|
6
|
+
# there is no client-side empty-payload check — mirroring the reference SDKs,
|
|
7
|
+
# the server returns `400` for a fully empty patch. Neither field is nullable,
|
|
8
|
+
# so there is no `Rerout::CLEAR` handling here.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# Rerout::UpdateTagInput.new(name: 'Renamed')
|
|
12
|
+
# Rerout::UpdateTagInput.new(color: 'red')
|
|
13
|
+
class UpdateTagInput
|
|
14
|
+
attr_reader :name, :color
|
|
15
|
+
|
|
16
|
+
# @param name [String, nil] new label. Omitted when not set.
|
|
17
|
+
# @param color [String, nil] new color. Omitted when not set.
|
|
18
|
+
def initialize(name: OMIT, color: OMIT)
|
|
19
|
+
@name = name
|
|
20
|
+
@color = color
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Serialize for the wire. Only fields that were set are included.
|
|
25
|
+
def to_h
|
|
26
|
+
hash = {}
|
|
27
|
+
hash['name'] = name unless name.equal?(OMIT)
|
|
28
|
+
hash['color'] = color unless color.equal?(OMIT)
|
|
29
|
+
hash
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Boolean] true when no field was set.
|
|
33
|
+
def empty?
|
|
34
|
+
to_h.empty?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/rerout/version.rb
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
|
|
5
|
+
module Rerout
|
|
6
|
+
module Resources
|
|
7
|
+
# Webhook endpoint management namespace — create, list, delete endpoints
|
|
8
|
+
# for the project that owns the API key.
|
|
9
|
+
#
|
|
10
|
+
# This is distinct from {Rerout::Webhooks}, which verifies *inbound*
|
|
11
|
+
# delivery signatures. Reach it via `client.webhooks`.
|
|
12
|
+
class Webhooks
|
|
13
|
+
# @param client [Rerout::Client]
|
|
14
|
+
def initialize(client)
|
|
15
|
+
@client = client
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Create a webhook endpoint. The returned `signing_secret` (`whsec_…`) is
|
|
19
|
+
# shown once — persist it to verify future deliveries.
|
|
20
|
+
#
|
|
21
|
+
# @param input [Rerout::CreateWebhookInput, Hash] the request body.
|
|
22
|
+
# @return [Rerout::Models::CreatedWebhook]
|
|
23
|
+
def create(input)
|
|
24
|
+
body = coerce_input(input)
|
|
25
|
+
response = @client.request(method: :post, path: '/v1/projects/me/webhooks', body: body)
|
|
26
|
+
Models::CreatedWebhook.from_hash(response)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# List webhook endpoints and the event types the server can deliver.
|
|
30
|
+
#
|
|
31
|
+
# @return [Rerout::Models::ListWebhooksResult]
|
|
32
|
+
def list
|
|
33
|
+
response = @client.request(method: :get, path: '/v1/projects/me/webhooks')
|
|
34
|
+
Models::ListWebhooksResult.from_hash(response)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Soft-delete an endpoint and abandon its pending deliveries. Idempotent.
|
|
38
|
+
#
|
|
39
|
+
# @param endpoint_id [String] the endpoint id (`wh_…`).
|
|
40
|
+
# @return [Hash] `{ "deleted" => true }`
|
|
41
|
+
def delete(endpoint_id)
|
|
42
|
+
@client.request(method: :delete, path: webhook_path(endpoint_id))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def webhook_path(endpoint_id)
|
|
48
|
+
raise ArgumentError, 'endpoint_id is required' if endpoint_id.nil? || endpoint_id.to_s.empty?
|
|
49
|
+
|
|
50
|
+
"/v1/projects/me/webhooks/#{ERB::Util.url_encode(endpoint_id.to_s)}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def coerce_input(input)
|
|
54
|
+
case input
|
|
55
|
+
when CreateWebhookInput then input.to_h
|
|
56
|
+
when Hash then input
|
|
57
|
+
else
|
|
58
|
+
raise ArgumentError, 'input must be a Rerout::CreateWebhookInput or Hash'
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/rerout.rb
CHANGED
|
@@ -3,13 +3,20 @@
|
|
|
3
3
|
require_relative 'rerout/version'
|
|
4
4
|
require_relative 'rerout/error'
|
|
5
5
|
require_relative 'rerout/models'
|
|
6
|
+
require_relative 'rerout/input_serialization'
|
|
6
7
|
require_relative 'rerout/create_link_input'
|
|
7
8
|
require_relative 'rerout/update_link_input'
|
|
9
|
+
require_relative 'rerout/create_webhook_input'
|
|
10
|
+
require_relative 'rerout/create_tag_input'
|
|
11
|
+
require_relative 'rerout/update_tag_input'
|
|
8
12
|
require_relative 'rerout/qr_options'
|
|
9
13
|
require_relative 'rerout/webhooks'
|
|
10
14
|
require_relative 'rerout/links'
|
|
11
15
|
require_relative 'rerout/project'
|
|
12
16
|
require_relative 'rerout/qr'
|
|
17
|
+
require_relative 'rerout/webhooks_resource'
|
|
18
|
+
require_relative 'rerout/conversions_resource'
|
|
19
|
+
require_relative 'rerout/tags_resource'
|
|
13
20
|
require_relative 'rerout/client'
|
|
14
21
|
|
|
15
22
|
# Official Ruby SDK for the Rerout branded-link API.
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rerout
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Codecraft Solutions
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: faraday
|
|
@@ -109,16 +108,23 @@ files:
|
|
|
109
108
|
- README.md
|
|
110
109
|
- lib/rerout.rb
|
|
111
110
|
- lib/rerout/client.rb
|
|
111
|
+
- lib/rerout/conversions_resource.rb
|
|
112
112
|
- lib/rerout/create_link_input.rb
|
|
113
|
+
- lib/rerout/create_tag_input.rb
|
|
114
|
+
- lib/rerout/create_webhook_input.rb
|
|
113
115
|
- lib/rerout/error.rb
|
|
116
|
+
- lib/rerout/input_serialization.rb
|
|
114
117
|
- lib/rerout/links.rb
|
|
115
118
|
- lib/rerout/models.rb
|
|
116
119
|
- lib/rerout/project.rb
|
|
117
120
|
- lib/rerout/qr.rb
|
|
118
121
|
- lib/rerout/qr_options.rb
|
|
122
|
+
- lib/rerout/tags_resource.rb
|
|
119
123
|
- lib/rerout/update_link_input.rb
|
|
124
|
+
- lib/rerout/update_tag_input.rb
|
|
120
125
|
- lib/rerout/version.rb
|
|
121
126
|
- lib/rerout/webhooks.rb
|
|
127
|
+
- lib/rerout/webhooks_resource.rb
|
|
122
128
|
homepage: https://github.com/ModestNerds-Co/rerout-sdks
|
|
123
129
|
licenses:
|
|
124
130
|
- MIT
|
|
@@ -129,7 +135,6 @@ metadata:
|
|
|
129
135
|
bug_tracker_uri: https://github.com/ModestNerds-Co/rerout-sdks/issues
|
|
130
136
|
documentation_uri: https://rerout.co/docs
|
|
131
137
|
rubygems_mfa_required: 'true'
|
|
132
|
-
post_install_message:
|
|
133
138
|
rdoc_options: []
|
|
134
139
|
require_paths:
|
|
135
140
|
- lib
|
|
@@ -144,8 +149,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
144
149
|
- !ruby/object:Gem::Version
|
|
145
150
|
version: '0'
|
|
146
151
|
requirements: []
|
|
147
|
-
rubygems_version: 3.
|
|
148
|
-
signing_key:
|
|
152
|
+
rubygems_version: 3.6.9
|
|
149
153
|
specification_version: 4
|
|
150
154
|
summary: Official Ruby SDK for the Rerout branded-link API.
|
|
151
155
|
test_files: []
|