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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f45e9af9b2dfce2933563d97fc0adca857d4cdca00a09fdafe89240fcdc1671f
|
|
4
|
+
data.tar.gz: ed1bbdcea909d9be88f5af98fccfcf4090de2ce1d3f30fb060ee81bc091f6369
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 974f74ad677142b918c0d9c367a785f6c2ae6198122ebaf75ee7d6f55e2b698883cbb01636f976d838bec3346cd96bc02e6da7b36da6adf92f49985feeb09650
|
|
7
|
+
data.tar.gz: cdc59d4419f34a6e92ccc0765c6aeb03a773c1776bf966a8858bbfc11ea584a5349fdc16e0018fcf7f6aab6dbec93ce250de36dfab849f3f644561ecd1baaa77
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the `rerout` gem are documented in this file. The
|
|
4
|
+
format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-05-20
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Initial public release.
|
|
12
|
+
- `Rerout::Client` with `links`, `project`, and `qr` namespaces and an
|
|
13
|
+
injectable Faraday connection for tests and edge deployments.
|
|
14
|
+
- Link operations: `create`, `list`, `get`, `update`, `delete`, `stats`.
|
|
15
|
+
- Project operations: `stats`, `me`.
|
|
16
|
+
- QR helpers: pure URL builder (`qr.url`) and authenticated SVG fetch
|
|
17
|
+
(`qr.svg`).
|
|
18
|
+
- `Rerout::CreateLinkInput` and `Rerout::UpdateLinkInput` request bodies, with
|
|
19
|
+
the `Rerout::CLEAR` sentinel to distinguish "leave alone" from "set null".
|
|
20
|
+
- `Rerout::QrOptions` with `ecc` validation and `refresh` coercion.
|
|
21
|
+
- Frozen value models: `Link`, `LinkStats`, `ProjectStats`, `DailyClicksPoint`,
|
|
22
|
+
`StatsBreakdown`, `ListLinksResult`, `Project`.
|
|
23
|
+
- `Rerout::Webhooks.verify_signature` (and the `Rerout.verify_signature`
|
|
24
|
+
shortcut) — HMAC-SHA256 webhook signature verification with configurable
|
|
25
|
+
timestamp tolerance and constant-time comparison.
|
|
26
|
+
- `Rerout::Error` with stable `code`, `status`, `path`, `timestamp`, `details`
|
|
27
|
+
plus `rate_limited?` and `server_error?` convenience flags.
|
|
28
|
+
|
|
29
|
+
[0.1.0]: https://github.com/ModestNerds-Co/rerout-sdks/releases/tag/ruby-v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Codecraft Solutions
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# rerout
|
|
2
|
+
|
|
3
|
+
Official Ruby SDK for the [Rerout](https://rerout.co) API.
|
|
4
|
+
|
|
5
|
+
Branded link infrastructure on Cloudflare — create short links, render QR
|
|
6
|
+
codes, read analytics, and verify webhook signatures.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
Add to your `Gemfile`:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem 'rerout'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Then run `bundle install`. Or install it directly:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
gem install rerout
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires Ruby 3.0+. Built on [Faraday](https://lostisland.github.io/faraday/)
|
|
23
|
+
2.x — the HTTP connection is injectable, so the same client runs against the
|
|
24
|
+
real API in production and a stubbed adapter in tests.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
require 'rerout'
|
|
30
|
+
|
|
31
|
+
rerout = Rerout::Client.new(api_key: ENV.fetch('REROUT_API_KEY'))
|
|
32
|
+
|
|
33
|
+
link = rerout.links.create(
|
|
34
|
+
Rerout::CreateLinkInput.new(
|
|
35
|
+
target_url: 'https://example.com/q4-sale',
|
|
36
|
+
domain_hostname: 'go.brand.com',
|
|
37
|
+
code: 'q4'
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
puts link.short_url # => https://go.brand.com/q4
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Construction
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
# Production — only the API key is required.
|
|
48
|
+
rerout = Rerout::Client.new(api_key: ENV.fetch('REROUT_API_KEY'))
|
|
49
|
+
|
|
50
|
+
# Staging / self-hosted — override the base URL (trailing slashes are trimmed).
|
|
51
|
+
rerout = Rerout::Client.new(
|
|
52
|
+
api_key: ENV.fetch('REROUT_API_KEY'),
|
|
53
|
+
base_url: 'https://staging.rerout.co'
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Custom timeout (seconds), User-Agent, or a shared Faraday connection.
|
|
57
|
+
rerout = Rerout::Client.new(
|
|
58
|
+
api_key: ENV.fetch('REROUT_API_KEY'),
|
|
59
|
+
timeout: 10,
|
|
60
|
+
user_agent: 'my-app/2.1'
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
A blank or missing `api_key` raises `Rerout::Error` with code `missing_api_key`
|
|
65
|
+
before any network call.
|
|
66
|
+
|
|
67
|
+
The client exposes three namespaces: `links`, `project`, and `qr`.
|
|
68
|
+
|
|
69
|
+
## Links
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
# Create
|
|
73
|
+
link = rerout.links.create(
|
|
74
|
+
Rerout::CreateLinkInput.new(target_url: 'https://example.com')
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# List (paginated)
|
|
78
|
+
page = rerout.links.list(limit: 25)
|
|
79
|
+
page.links # => [Rerout::Models::Link, ...]
|
|
80
|
+
page.next_cursor # => Integer or nil
|
|
81
|
+
page = rerout.links.list(cursor: page.next_cursor) if page.next_cursor
|
|
82
|
+
|
|
83
|
+
# Get one
|
|
84
|
+
link = rerout.links.get('q4')
|
|
85
|
+
|
|
86
|
+
# Update — only the fields you set are sent.
|
|
87
|
+
rerout.links.update('q4', Rerout::UpdateLinkInput.new(is_active: false))
|
|
88
|
+
|
|
89
|
+
# Delete (soft delete)
|
|
90
|
+
rerout.links.delete('q4') # => { "deleted" => true }
|
|
91
|
+
|
|
92
|
+
# Per-link stats (defaults to 30 days)
|
|
93
|
+
stats = rerout.links.stats('q4', days: 7)
|
|
94
|
+
stats.total_clicks
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Clearing fields on update
|
|
98
|
+
|
|
99
|
+
`Rerout::UpdateLinkInput` distinguishes *"leave this field alone"* from *"set
|
|
100
|
+
this field to null on the server"*. Pass `Rerout::CLEAR` to null a field:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
# Sends { "expires_at": null } — removes the link's expiry.
|
|
104
|
+
rerout.links.update('q4', Rerout::UpdateLinkInput.new(expires_at: Rerout::CLEAR))
|
|
105
|
+
|
|
106
|
+
# Sends { "target_url": "https://new.example.com" } — leaves everything else.
|
|
107
|
+
rerout.links.update('q4', Rerout::UpdateLinkInput.new(target_url: 'https://new.example.com'))
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
An `UpdateLinkInput` with no fields set raises `Rerout::Error` (code
|
|
111
|
+
`empty_update`) client-side without hitting the API.
|
|
112
|
+
|
|
113
|
+
## Project
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
# Aggregate stats across every link (defaults to 30 days).
|
|
117
|
+
stats = rerout.project.stats(days: 30)
|
|
118
|
+
stats.total_clicks
|
|
119
|
+
stats.daily # => [Rerout::Models::DailyClicksPoint, ...]
|
|
120
|
+
stats.top_codes # => [Rerout::Models::StatsBreakdown, ...]
|
|
121
|
+
|
|
122
|
+
# Identity of the project that owns the API key.
|
|
123
|
+
me = rerout.project.me
|
|
124
|
+
me.slug
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## QR
|
|
128
|
+
|
|
129
|
+
`qr.url` is a pure builder — it never touches the network:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
rerout.qr.url('q4')
|
|
133
|
+
# => "https://api.rerout.co/v1/links/q4/qr"
|
|
134
|
+
|
|
135
|
+
rerout.qr.url('q4', Rerout::QrOptions.new(size: 12, ecc: 'H', domain: 'go.brand.com'))
|
|
136
|
+
# => "https://api.rerout.co/v1/links/q4/qr?size=12&ecc=H&domain=go.brand.com"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`qr.svg` fetches the rendered SVG from the API with the bearer token attached:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
svg = rerout.qr.svg('q4', Rerout::QrOptions.new(size: 16))
|
|
143
|
+
File.write('q4.svg', svg)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
QR options: `size` (1–32), `margin` (0–16), `ecc` (`L`/`M`/`Q`/`H`), `domain`,
|
|
147
|
+
and `refresh` (`true` is serialized as `1`; a string is sent verbatim).
|
|
148
|
+
|
|
149
|
+
## Webhook signature verification
|
|
150
|
+
|
|
151
|
+
Rerout signs every webhook delivery with an `X-Rerout-Signature` header. Verify
|
|
152
|
+
it before trusting the payload:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
ok = Rerout::Webhooks.verify_signature(
|
|
156
|
+
raw_body: request.raw_post,
|
|
157
|
+
signature_header: request.headers['X-Rerout-Signature'],
|
|
158
|
+
secret: ENV.fetch('REROUT_WEBHOOK_SECRET')
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
head(:unauthorized) and return unless ok
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`Rerout.verify_signature` is a module-level shortcut for the same method. The
|
|
165
|
+
HMAC-SHA256 comparison runs in constant time, and a five-minute timestamp
|
|
166
|
+
tolerance guards against replay attacks. Pass `tolerance_seconds: 0` to disable
|
|
167
|
+
the timestamp check. The method never raises — it returns `false` for every
|
|
168
|
+
failure mode (malformed header, wrong secret, stale timestamp, tampered body).
|
|
169
|
+
|
|
170
|
+
## Error handling
|
|
171
|
+
|
|
172
|
+
Every failure raises `Rerout::Error`:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
begin
|
|
176
|
+
rerout.links.get('does-not-exist')
|
|
177
|
+
rescue Rerout::Error => e
|
|
178
|
+
e.code # => "not_found" (stable string, API or synthetic)
|
|
179
|
+
e.status # => 404 (HTTP status, or 0 for network/timeout failures)
|
|
180
|
+
e.message # => human-readable description
|
|
181
|
+
e.path # => API path, when supplied by the server
|
|
182
|
+
e.timestamp # => server timestamp, when supplied
|
|
183
|
+
e.rate_limited? # => true when status == 429
|
|
184
|
+
e.server_error? # => true for HTTP 5xx
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
When the server responds without a JSON body the SDK fills in a synthetic
|
|
189
|
+
`code`: `unauthorized` (401), `forbidden` (403), `not_found` (404),
|
|
190
|
+
`rate_limited` (429), `server_error` (5xx), `client_error` (other 4xx),
|
|
191
|
+
`network_error` (connection failure), `timeout`, and `unexpected_response`
|
|
192
|
+
(a 2xx body that is not valid JSON).
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT — see [LICENSE](LICENSE), a copy of the workspace
|
|
197
|
+
[LICENSE](https://github.com/ModestNerds-Co/rerout-sdks/blob/main/LICENSE).
|
|
198
|
+
|
|
199
|
+
## Links
|
|
200
|
+
|
|
201
|
+
- API docs: <https://rerout.co/docs>
|
|
202
|
+
- Source: <https://github.com/ModestNerds-Co/rerout-sdks>
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
require_relative 'version'
|
|
7
|
+
require_relative 'error'
|
|
8
|
+
require_relative 'create_link_input'
|
|
9
|
+
require_relative 'update_link_input'
|
|
10
|
+
require_relative 'qr_options'
|
|
11
|
+
require_relative 'webhooks'
|
|
12
|
+
require_relative 'models'
|
|
13
|
+
require_relative 'links'
|
|
14
|
+
require_relative 'project'
|
|
15
|
+
require_relative 'qr'
|
|
16
|
+
|
|
17
|
+
module Rerout
|
|
18
|
+
# Default production API base URL.
|
|
19
|
+
DEFAULT_BASE_URL = 'https://api.rerout.co'
|
|
20
|
+
|
|
21
|
+
# Main entry point — construct one of these per project API key and re-use
|
|
22
|
+
# it across requests. Thread-safe so long as the injected Faraday connection
|
|
23
|
+
# is thread-safe (Faraday's default `Net::HTTP` adapter is).
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# rerout = Rerout::Client.new(api_key: ENV.fetch('REROUT_API_KEY'))
|
|
27
|
+
# link = rerout.links.create(Rerout::CreateLinkInput.new(target_url: 'https://example.com'))
|
|
28
|
+
# puts link.short_url
|
|
29
|
+
class Client
|
|
30
|
+
# @return [String] resolved base URL with trailing slashes stripped.
|
|
31
|
+
attr_reader :base_url
|
|
32
|
+
|
|
33
|
+
# @return [Resources::Links] link namespace.
|
|
34
|
+
attr_reader :links
|
|
35
|
+
# @return [Resources::Project] project namespace.
|
|
36
|
+
attr_reader :project
|
|
37
|
+
# @return [Resources::Qr] QR namespace.
|
|
38
|
+
attr_reader :qr
|
|
39
|
+
|
|
40
|
+
# @param api_key [String] project API key (`rrk_…`). Required.
|
|
41
|
+
# @param base_url [String, nil] override base URL. Defaults to `https://api.rerout.co`.
|
|
42
|
+
# @param connection [Faraday::Connection, nil] inject a Faraday connection
|
|
43
|
+
# (useful for the test adapter or for sharing connection pools).
|
|
44
|
+
# @param timeout [Integer] per-request timeout in seconds. Default 30.
|
|
45
|
+
# @param user_agent [String, nil] override the default `User-Agent` header.
|
|
46
|
+
def initialize(api_key:, base_url: nil, connection: nil, timeout: 30, user_agent: nil)
|
|
47
|
+
if api_key.nil? || !api_key.is_a?(String) || api_key.strip.empty?
|
|
48
|
+
raise Error.new(
|
|
49
|
+
code: 'missing_api_key',
|
|
50
|
+
message: 'A project API key is required to construct Rerout::Client.',
|
|
51
|
+
status: 0
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@api_key = api_key
|
|
56
|
+
@base_url = (base_url || DEFAULT_BASE_URL).to_s.sub(%r{/+\z}, '')
|
|
57
|
+
@timeout = timeout
|
|
58
|
+
@user_agent = user_agent || "rerout-ruby/#{Rerout::VERSION}"
|
|
59
|
+
@connection = connection || default_connection
|
|
60
|
+
|
|
61
|
+
@links = Resources::Links.new(self)
|
|
62
|
+
@project = Resources::Project.new(self)
|
|
63
|
+
@qr = Resources::Qr.new(self)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Perform a JSON request against the Rerout API.
|
|
67
|
+
#
|
|
68
|
+
# @api private
|
|
69
|
+
# @param method [Symbol] :get, :post, :patch, :delete
|
|
70
|
+
# @param path [String] starts with `/`, includes any path params.
|
|
71
|
+
# @param query [Hash, nil] query string params.
|
|
72
|
+
# @param body [Object, nil] body to be JSON-encoded.
|
|
73
|
+
# @return [Hash, Array, String, nil] parsed JSON body, raw text for non-JSON
|
|
74
|
+
# success bodies that the caller opted into via `raw: true`, or nil for
|
|
75
|
+
# 204 No Content.
|
|
76
|
+
def request(method:, path:, query: nil, body: nil, raw: false)
|
|
77
|
+
headers = base_headers
|
|
78
|
+
payload = nil
|
|
79
|
+
if body
|
|
80
|
+
payload = JSON.generate(body)
|
|
81
|
+
headers['Content-Type'] = 'application/json'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
response = perform_request(method: method, path: path, query: query,
|
|
85
|
+
headers: headers, payload: payload)
|
|
86
|
+
|
|
87
|
+
handle_response(response, raw: raw)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def perform_request(method:, path:, query:, headers:, payload:)
|
|
93
|
+
full_url = "#{@base_url}#{path}"
|
|
94
|
+
@connection.public_send(method) do |req|
|
|
95
|
+
req.url(full_url)
|
|
96
|
+
req.params.update(query) if query && !query.empty?
|
|
97
|
+
headers.each { |k, v| req.headers[k] = v }
|
|
98
|
+
req.body = payload if payload
|
|
99
|
+
req.options.timeout = @timeout if @timeout
|
|
100
|
+
req.options.open_timeout = @timeout if @timeout
|
|
101
|
+
end
|
|
102
|
+
rescue Faraday::TimeoutError => e
|
|
103
|
+
raise Error.new(code: 'timeout', message: e.message || 'Request timed out.', status: 0, details: e)
|
|
104
|
+
rescue Faraday::ConnectionFailed, Faraday::SSLError => e
|
|
105
|
+
raise Error.new(code: 'network_error', message: e.message || 'Network failure.', status: 0, details: e)
|
|
106
|
+
rescue Faraday::Error => e
|
|
107
|
+
raise Error.new(code: 'network_error', message: e.message || 'Faraday error.', status: 0, details: e)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def base_headers
|
|
111
|
+
{
|
|
112
|
+
'Authorization' => "Bearer #{@api_key}",
|
|
113
|
+
'Accept' => 'application/json',
|
|
114
|
+
'User-Agent' => @user_agent
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def handle_response(response, raw:)
|
|
119
|
+
status = response.status
|
|
120
|
+
body = response.body.to_s
|
|
121
|
+
|
|
122
|
+
raise parse_error(status, body) unless status.between?(200, 299)
|
|
123
|
+
return nil if status == 204 || body.empty?
|
|
124
|
+
return body if raw
|
|
125
|
+
|
|
126
|
+
begin
|
|
127
|
+
JSON.parse(body)
|
|
128
|
+
rescue JSON::ParserError => e
|
|
129
|
+
raise Error.new(
|
|
130
|
+
code: 'unexpected_response',
|
|
131
|
+
message: 'Rerout returned a non-JSON success body.',
|
|
132
|
+
status: status,
|
|
133
|
+
details: { body: body, error: e.message }
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def parse_error(status, body)
|
|
139
|
+
if body.empty?
|
|
140
|
+
return Error.new(
|
|
141
|
+
code: synthetic_code(status),
|
|
142
|
+
message: "Rerout returned HTTP #{status} with no body.",
|
|
143
|
+
status: status
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
begin
|
|
148
|
+
parsed = JSON.parse(body)
|
|
149
|
+
rescue JSON::ParserError
|
|
150
|
+
return Error.new(
|
|
151
|
+
code: synthetic_code(status),
|
|
152
|
+
message: "Rerout returned HTTP #{status} (non-JSON body).",
|
|
153
|
+
status: status,
|
|
154
|
+
details: { body: body }
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
Error.new(
|
|
159
|
+
code: parsed['code'] || synthetic_code(status),
|
|
160
|
+
message: parsed['message'] || "Rerout returned HTTP #{status}.",
|
|
161
|
+
status: status,
|
|
162
|
+
path: parsed['path'],
|
|
163
|
+
timestamp: parsed['timestamp'],
|
|
164
|
+
details: parsed
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def synthetic_code(status)
|
|
169
|
+
case status
|
|
170
|
+
when 401 then 'unauthorized'
|
|
171
|
+
when 403 then 'forbidden'
|
|
172
|
+
when 404 then 'not_found'
|
|
173
|
+
when 429 then 'rate_limited'
|
|
174
|
+
when 500..599 then 'server_error'
|
|
175
|
+
else 'client_error'
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def default_connection
|
|
180
|
+
Faraday.new(url: @base_url) do |conn|
|
|
181
|
+
conn.request :url_encoded
|
|
182
|
+
conn.adapter Faraday.default_adapter
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rerout
|
|
4
|
+
# Request body for `POST /v1/links`. Only `target_url` is required —
|
|
5
|
+
# everything else is optional and omitted from the payload when not set.
|
|
6
|
+
class CreateLinkInput
|
|
7
|
+
attr_reader :target_url, :domain_hostname, :code, :expires_at,
|
|
8
|
+
:seo_title, :seo_description, :seo_image_url,
|
|
9
|
+
:seo_canonical_url, :seo_noindex
|
|
10
|
+
|
|
11
|
+
# @param target_url [String] required, the destination URL.
|
|
12
|
+
# @param domain_hostname [String, nil] verified custom domain (e.g. `go.brand.com`).
|
|
13
|
+
# @param code [String, nil] custom path. Only allowed with a verified `domain_hostname`.
|
|
14
|
+
# @param expires_at [Integer, nil] unix seconds.
|
|
15
|
+
# @param seo_title [String, nil]
|
|
16
|
+
# @param seo_description [String, nil]
|
|
17
|
+
# @param seo_image_url [String, nil] absolute https:// URL.
|
|
18
|
+
# @param seo_canonical_url [String, nil]
|
|
19
|
+
# @param seo_noindex [Boolean, nil] default server-side: `true`.
|
|
20
|
+
def initialize(target_url:, domain_hostname: nil, code: nil, expires_at: nil,
|
|
21
|
+
seo_title: nil, seo_description: nil, seo_image_url: nil,
|
|
22
|
+
seo_canonical_url: nil, seo_noindex: nil)
|
|
23
|
+
raise ArgumentError, 'target_url is required' if target_url.nil? || target_url.to_s.empty?
|
|
24
|
+
|
|
25
|
+
@target_url = target_url
|
|
26
|
+
@domain_hostname = domain_hostname
|
|
27
|
+
@code = code
|
|
28
|
+
@expires_at = expires_at
|
|
29
|
+
@seo_title = seo_title
|
|
30
|
+
@seo_description = seo_description
|
|
31
|
+
@seo_image_url = seo_image_url
|
|
32
|
+
@seo_canonical_url = seo_canonical_url
|
|
33
|
+
@seo_noindex = seo_noindex
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Serialize for the wire. Fields are only included when set.
|
|
38
|
+
def to_h
|
|
39
|
+
hash = { 'target_url' => target_url }
|
|
40
|
+
hash['domain_hostname'] = domain_hostname unless domain_hostname.nil?
|
|
41
|
+
hash['code'] = code unless code.nil?
|
|
42
|
+
hash['expires_at'] = expires_at unless expires_at.nil?
|
|
43
|
+
hash['seo_title'] = seo_title unless seo_title.nil?
|
|
44
|
+
hash['seo_description'] = seo_description unless seo_description.nil?
|
|
45
|
+
hash['seo_image_url'] = seo_image_url unless seo_image_url.nil?
|
|
46
|
+
hash['seo_canonical_url'] = seo_canonical_url unless seo_canonical_url.nil?
|
|
47
|
+
hash['seo_noindex'] = seo_noindex unless seo_noindex.nil?
|
|
48
|
+
hash
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/rerout/error.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rerout
|
|
4
|
+
# Raised for any Rerout API failure — bad request, auth issue, rate limit,
|
|
5
|
+
# network failure, timeout, or unparseable response.
|
|
6
|
+
#
|
|
7
|
+
# The {#code} field carries the stable string identifier returned by the
|
|
8
|
+
# Rerout API (e.g. `bad_target_url`, `rate_limited`, `not_found`) so callers
|
|
9
|
+
# can branch on it without parsing the human-readable {#message}.
|
|
10
|
+
#
|
|
11
|
+
# For network/timeout/parse failures the {#code} is one of the synthetic
|
|
12
|
+
# values: `network_error`, `timeout`, `unexpected_response`, `unauthorized`,
|
|
13
|
+
# `forbidden`, `not_found`, `rate_limited`, `server_error`, `client_error`,
|
|
14
|
+
# `missing_api_key`.
|
|
15
|
+
class Error < StandardError
|
|
16
|
+
# @return [String] stable error code, either from the API or synthetic.
|
|
17
|
+
attr_reader :code
|
|
18
|
+
|
|
19
|
+
# @return [Integer] HTTP status, or 0 when the request never reached the server.
|
|
20
|
+
attr_reader :status
|
|
21
|
+
|
|
22
|
+
# @return [String, nil] API path that returned the error, when available.
|
|
23
|
+
attr_reader :path
|
|
24
|
+
|
|
25
|
+
# @return [String, nil] ISO-8601 server timestamp, when supplied.
|
|
26
|
+
attr_reader :timestamp
|
|
27
|
+
|
|
28
|
+
# @return [Object, nil] raw parsed payload or original cause.
|
|
29
|
+
attr_reader :details
|
|
30
|
+
|
|
31
|
+
def initialize(message:, code:, status: 0, path: nil, timestamp: nil, details: nil)
|
|
32
|
+
super(message)
|
|
33
|
+
@code = code
|
|
34
|
+
@status = status
|
|
35
|
+
@path = path
|
|
36
|
+
@timestamp = timestamp
|
|
37
|
+
@details = details
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Boolean] true when the failure is HTTP 429.
|
|
41
|
+
def rate_limited?
|
|
42
|
+
status == 429
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Boolean] true when the failure is HTTP 5xx.
|
|
46
|
+
def server_error?
|
|
47
|
+
status >= 500 && status < 600
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A developer-friendly description. Kept separate from {#message} (the raw
|
|
51
|
+
# human message) so logging the error shows the structured fields without
|
|
52
|
+
# the bare message losing them.
|
|
53
|
+
def inspect
|
|
54
|
+
"#<Rerout::Error code=#{code.inspect} status=#{status} " \
|
|
55
|
+
"message=#{message.inspect} path=#{path.inspect} " \
|
|
56
|
+
"timestamp=#{timestamp.inspect}>"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/rerout/links.rb
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
|
|
5
|
+
module Rerout
|
|
6
|
+
module Resources
|
|
7
|
+
# Link operations namespace.
|
|
8
|
+
class Links
|
|
9
|
+
# @param client [Rerout::Client]
|
|
10
|
+
def initialize(client)
|
|
11
|
+
@client = client
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Create a new short link.
|
|
15
|
+
#
|
|
16
|
+
# @param input [Rerout::CreateLinkInput, Hash] the request body.
|
|
17
|
+
# @return [Rerout::Models::Link]
|
|
18
|
+
def create(input)
|
|
19
|
+
body = coerce_input(input)
|
|
20
|
+
response = @client.request(method: :post, path: '/v1/links', body: body)
|
|
21
|
+
Models::Link.from_hash(response)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Paginated list of links.
|
|
25
|
+
#
|
|
26
|
+
# @param cursor [Integer, nil]
|
|
27
|
+
# @param limit [Integer, nil]
|
|
28
|
+
# @return [Rerout::Models::ListLinksResult]
|
|
29
|
+
def list(cursor: nil, limit: nil)
|
|
30
|
+
query = {}
|
|
31
|
+
query['cursor'] = cursor unless cursor.nil?
|
|
32
|
+
query['limit'] = limit unless limit.nil?
|
|
33
|
+
response = @client.request(method: :get, path: '/v1/links', query: query)
|
|
34
|
+
Models::ListLinksResult.from_hash(response)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Fetch a single link.
|
|
38
|
+
#
|
|
39
|
+
# @param code [String]
|
|
40
|
+
# @return [Rerout::Models::Link]
|
|
41
|
+
def get(code)
|
|
42
|
+
response = @client.request(method: :get, path: link_path(code))
|
|
43
|
+
Models::Link.from_hash(response)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Update a link. Only fields set on `input` are sent.
|
|
47
|
+
#
|
|
48
|
+
# @param code [String]
|
|
49
|
+
# @param input [Rerout::UpdateLinkInput]
|
|
50
|
+
# @return [Rerout::Models::Link]
|
|
51
|
+
def update(code, input)
|
|
52
|
+
raise ArgumentError, 'input must be a Rerout::UpdateLinkInput' unless input.is_a?(UpdateLinkInput)
|
|
53
|
+
if input.empty?
|
|
54
|
+
raise Error.new(
|
|
55
|
+
code: 'empty_update',
|
|
56
|
+
message: 'UpdateLinkInput has no fields set; refusing to send empty PATCH.',
|
|
57
|
+
status: 0
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
response = @client.request(method: :patch, path: link_path(code), body: input.to_h)
|
|
62
|
+
Models::Link.from_hash(response)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Soft-delete a link.
|
|
66
|
+
#
|
|
67
|
+
# @param code [String]
|
|
68
|
+
# @return [Hash] `{ "deleted" => true }`
|
|
69
|
+
def delete(code)
|
|
70
|
+
@client.request(method: :delete, path: link_path(code))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Per-link click stats. Defaults to 30 days.
|
|
74
|
+
#
|
|
75
|
+
# @param code [String]
|
|
76
|
+
# @param days [Integer]
|
|
77
|
+
# @return [Rerout::Models::LinkStats]
|
|
78
|
+
def stats(code, days: 30)
|
|
79
|
+
response = @client.request(
|
|
80
|
+
method: :get,
|
|
81
|
+
path: "#{link_path(code)}/stats",
|
|
82
|
+
query: { 'days' => days }
|
|
83
|
+
)
|
|
84
|
+
Models::LinkStats.from_hash(response)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def link_path(code)
|
|
90
|
+
raise ArgumentError, 'code is required' if code.nil? || code.to_s.empty?
|
|
91
|
+
|
|
92
|
+
"/v1/links/#{ERB::Util.url_encode(code.to_s)}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def coerce_input(input)
|
|
96
|
+
case input
|
|
97
|
+
when CreateLinkInput then input.to_h
|
|
98
|
+
when Hash then input
|
|
99
|
+
else
|
|
100
|
+
raise ArgumentError, 'input must be a Rerout::CreateLinkInput or Hash'
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|