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 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
@@ -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
@@ -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