tly-url-shortener-api 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: 6069fa9be1fa0d2823f085082d68ce8ecec96b178ed0ecbca27370d2f70c6415
4
+ data.tar.gz: 74e359fee6e6c76f39e91efb6b855aaadc3f55dabb7fffa3195c84b053c70825
5
+ SHA512:
6
+ metadata.gz: a970c9ecb36366d6a4fd392a52b3d070d39f04edede66c7791a2b3ef319af4e4cd0089fd2f0980f5972ce55cda5df357ed3f8066f67c56bd0c10bf288a5ef3fe
7
+ data.tar.gz: 113411facbe63af3f98062d13ed1801df7cda70acd5f494a4d538801c794f0b0fa3531eafcc9bc62fdbcc922481be281132e3dce3bc7d3771767a3a5f634e9ca
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-02-17
4
+
5
+ - Initial release.
6
+ - Added full Ruby client coverage for T.LY endpoints in the provided Postman collection.
7
+ - Added typed error handling and response wrapper.
8
+ - Added test suite and gem packaging files.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 T.LY
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,172 @@
1
+ # T.LY Ruby URL Shortener API
2
+
3
+ Ruby client library for the [T.LY API](https://t.ly).
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem "tly-url-shortener-api"
11
+ ```
12
+
13
+ Then run:
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ Or install directly:
20
+
21
+ ```bash
22
+ gem install tly-url-shortener-api
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```ruby
28
+ require "tly_url_shortener_api"
29
+
30
+ client = Tly::UrlShortenerApi::Client.new(api_token: ENV.fetch("TLY_API_TOKEN"))
31
+
32
+ response = client.shorten_link(
33
+ long_url: "https://example.com/very/long/path",
34
+ domain: "https://t.ly",
35
+ description: "My short link"
36
+ )
37
+
38
+ puts response.status
39
+ puts response.body
40
+ ```
41
+
42
+ ## Authentication
43
+
44
+ This gem sends your API token as a bearer token header:
45
+
46
+ ```http
47
+ Authorization: Bearer <YOUR_TOKEN>
48
+ ```
49
+
50
+ ## Available API Methods
51
+
52
+ ### OneLink Stats
53
+
54
+ - `onelink_stats(short_url:, start_date: nil, end_date: nil)`
55
+ - `delete_onelink_stats(short_url:)`
56
+
57
+ ### ShortLink Management
58
+
59
+ - `shorten_link(long_url:, domain: nil, expire_at_datetime: nil, description: nil, public_stats: nil, meta: nil)`
60
+ - `get_link(short_url:)`
61
+ - `update_link(short_url:, long_url: nil, expire_at_datetime: nil, description: nil, public_stats: nil, meta: nil)`
62
+ - `delete_link(short_url:)`
63
+ - `expand_link(short_url:, password: nil)`
64
+ - `list_links(search: nil, tag_ids: nil, pixel_ids: nil, start_date: nil, end_date: nil, domains: nil)`
65
+ - `bulk_shorten_links(links:, domain: nil, tags: nil, pixels: nil)`
66
+ - `bulk_update_links(links:, tags: nil, pixels: nil)`
67
+
68
+ ### ShortLink Stats
69
+
70
+ - `link_stats(short_url:, start_date: nil, end_date: nil)`
71
+
72
+ ### UTM Presets
73
+
74
+ - `create_utm_preset(name:, source:, medium:, campaign:, content: nil, term: nil)`
75
+ - `list_utm_presets`
76
+ - `get_utm_preset(id:)`
77
+ - `update_utm_preset(id:, name: nil, source: nil, medium: nil, campaign: nil, content: nil, term: nil)`
78
+ - `delete_utm_preset(id:)`
79
+
80
+ ### OneLinks
81
+
82
+ - `list_onelinks(page: nil)`
83
+
84
+ ### Pixels
85
+
86
+ - `create_pixel(name:, pixel_id:, pixel_type:)`
87
+ - `list_pixels`
88
+ - `get_pixel(id:)`
89
+ - `update_pixel(id:, name: nil, pixel_id: nil, pixel_type: nil)`
90
+ - `delete_pixel(id:)`
91
+
92
+ ### QR Codes
93
+
94
+ - `get_qr_code(short_url:, output: nil, format: nil)`
95
+ - `update_qr_code(short_url:, image: nil, background_color: nil, corner_dots_color: nil, dots_color: nil, dots_style: nil, corner_style: nil)`
96
+
97
+ ### Tags
98
+
99
+ - `list_tags`
100
+ - `create_tag(tag:)`
101
+ - `get_tag(id:)`
102
+ - `update_tag(id:, tag:)`
103
+ - `delete_tag(id:)`
104
+
105
+ ## Response Object
106
+
107
+ Every method returns `Tly::UrlShortenerApi::Response` with:
108
+
109
+ - `status` - HTTP status code
110
+ - `headers` - response headers hash (lowercase keys)
111
+ - `body` - parsed JSON (Hash/Array) or raw body string
112
+ - `raw_body` - unparsed body string
113
+ - `success?` - true for HTTP 2xx
114
+
115
+ ## Error Handling
116
+
117
+ Failed responses raise typed errors:
118
+
119
+ - `Tly::UrlShortenerApi::AuthenticationError`
120
+ - `Tly::UrlShortenerApi::PermissionError`
121
+ - `Tly::UrlShortenerApi::NotFoundError`
122
+ - `Tly::UrlShortenerApi::ValidationError`
123
+ - `Tly::UrlShortenerApi::RateLimitError`
124
+ - `Tly::UrlShortenerApi::ServerError`
125
+ - `Tly::UrlShortenerApi::TransportError` (network/timeout/connection failures)
126
+
127
+ ```ruby
128
+ begin
129
+ client.get_link(short_url: "https://t.ly/missing")
130
+ rescue Tly::UrlShortenerApi::NotFoundError => e
131
+ puts e.status
132
+ puts e.response_body
133
+ end
134
+ ```
135
+
136
+ ## Configure Base URL and Timeouts
137
+
138
+ ```ruby
139
+ client = Tly::UrlShortenerApi::Client.new(
140
+ api_token: ENV.fetch("TLY_API_TOKEN"),
141
+ base_url: "https://api.t.ly",
142
+ open_timeout: 5,
143
+ read_timeout: 20
144
+ )
145
+ ```
146
+
147
+ ## Development
148
+
149
+ ```bash
150
+ bundle install
151
+ bundle exec rake test
152
+ ```
153
+
154
+ ## Release to RubyGems
155
+
156
+ 1. Update version in `lib/tly/url_shortener_api/version.rb`.
157
+ 2. Update `CHANGELOG.md`.
158
+ 3. Build the gem:
159
+
160
+ ```bash
161
+ gem build tly-url-shortener-api.gemspec
162
+ ```
163
+
164
+ 4. Push to RubyGems:
165
+
166
+ ```bash
167
+ gem push tly-url-shortener-api-<version>.gem
168
+ ```
169
+
170
+ ## License
171
+
172
+ MIT. See `LICENSE.txt`.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new do |t|
7
+ t.libs << "test"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,441 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ module Tly
9
+ module UrlShortenerApi
10
+ class Client
11
+ DEFAULT_BASE_URL = "https://api.t.ly"
12
+
13
+ attr_reader :api_token, :base_url, :open_timeout, :read_timeout, :user_agent
14
+
15
+ def initialize(api_token:, base_url: DEFAULT_BASE_URL, open_timeout: 10, read_timeout: 30, user_agent: default_user_agent)
16
+ token = api_token.to_s.strip
17
+ raise ArgumentError, "api_token is required" if token.empty?
18
+
19
+ @api_token = token
20
+ @base_url = normalize_base_url(base_url)
21
+ @open_timeout = open_timeout
22
+ @read_timeout = read_timeout
23
+ @user_agent = user_agent
24
+ end
25
+
26
+ # OneLink Stats Management
27
+ def onelink_stats(short_url:, start_date: nil, end_date: nil)
28
+ request(
29
+ :get,
30
+ "/api/v1/onelink/stats",
31
+ query: { short_url: short_url, start_date: start_date, end_date: end_date }
32
+ )
33
+ end
34
+
35
+ def delete_onelink_stats(short_url:)
36
+ request(:delete, "/api/v1/onelink/stat", body: { short_url: short_url })
37
+ end
38
+
39
+ # ShortLink Management
40
+ def shorten_link(long_url:, domain: nil, expire_at_datetime: nil, description: nil, public_stats: nil, meta: nil)
41
+ request(
42
+ :post,
43
+ "/api/v1/link/shorten",
44
+ body: {
45
+ long_url: long_url,
46
+ domain: domain,
47
+ expire_at_datetime: expire_at_datetime,
48
+ description: description,
49
+ public_stats: public_stats,
50
+ meta: meta
51
+ }
52
+ )
53
+ end
54
+
55
+ def get_link(short_url:)
56
+ request(:get, "/api/v1/link", query: { short_url: short_url })
57
+ end
58
+
59
+ def update_link(short_url:, long_url: nil, expire_at_datetime: nil, description: nil, public_stats: nil, meta: nil)
60
+ request(
61
+ :put,
62
+ "/api/v1/link",
63
+ body: {
64
+ short_url: short_url,
65
+ long_url: long_url,
66
+ expire_at_datetime: expire_at_datetime,
67
+ description: description,
68
+ public_stats: public_stats,
69
+ meta: meta
70
+ }
71
+ )
72
+ end
73
+
74
+ def delete_link(short_url:)
75
+ request(:delete, "/api/v1/link", body: { short_url: short_url })
76
+ end
77
+
78
+ def expand_link(short_url:, password: nil)
79
+ request(:post, "/api/v1/link/expand", body: { short_url: short_url, password: password })
80
+ end
81
+
82
+ def list_links(search: nil, tag_ids: nil, pixel_ids: nil, start_date: nil, end_date: nil, domains: nil)
83
+ request(
84
+ :get,
85
+ "/api/v1/link/list",
86
+ query: {
87
+ search: search,
88
+ tag_ids: tag_ids,
89
+ pixel_ids: pixel_ids,
90
+ start_date: start_date,
91
+ end_date: end_date,
92
+ domains: domains
93
+ }
94
+ )
95
+ end
96
+
97
+ def bulk_shorten_links(links:, domain: nil, tags: nil, pixels: nil)
98
+ request(
99
+ :post,
100
+ "/api/v1/link/bulk",
101
+ body: {
102
+ domain: domain,
103
+ links: links,
104
+ tags: tags,
105
+ pixels: pixels
106
+ }
107
+ )
108
+ end
109
+
110
+ def bulk_update_links(links:, tags: nil, pixels: nil)
111
+ request(
112
+ :post,
113
+ "/api/v1/link/bulk/update",
114
+ body: {
115
+ links: links,
116
+ tags: tags,
117
+ pixels: pixels
118
+ }
119
+ )
120
+ end
121
+
122
+ # ShortLink Stats
123
+ def link_stats(short_url:, start_date: nil, end_date: nil)
124
+ request(:get, "/api/v1/link/stats", query: { short_url: short_url, start_date: start_date, end_date: end_date })
125
+ end
126
+
127
+ # UTM Preset Management
128
+ def create_utm_preset(name:, source:, medium:, campaign:, content: nil, term: nil)
129
+ request(
130
+ :post,
131
+ "/api/v1/link/utm-preset",
132
+ body: {
133
+ name: name,
134
+ source: source,
135
+ medium: medium,
136
+ campaign: campaign,
137
+ content: content,
138
+ term: term
139
+ }
140
+ )
141
+ end
142
+
143
+ def list_utm_presets
144
+ request(:get, "/api/v1/link/utm-preset")
145
+ end
146
+
147
+ def get_utm_preset(id:)
148
+ request(:get, path_with_id("/api/v1/link/utm-preset/:id", id))
149
+ end
150
+
151
+ def update_utm_preset(id:, name: nil, source: nil, medium: nil, campaign: nil, content: nil, term: nil)
152
+ request(
153
+ :put,
154
+ path_with_id("/api/v1/link/utm-preset/:id", id),
155
+ body: {
156
+ name: name,
157
+ source: source,
158
+ medium: medium,
159
+ campaign: campaign,
160
+ content: content,
161
+ term: term
162
+ }
163
+ )
164
+ end
165
+
166
+ def delete_utm_preset(id:)
167
+ request(:delete, path_with_id("/api/v1/link/utm-preset/:id", id))
168
+ end
169
+
170
+ # OneLink Management
171
+ def list_onelinks(page: nil)
172
+ request(:get, "/api/v1/onelink/list", query: { page: page })
173
+ end
174
+
175
+ # Pixel Management
176
+ def create_pixel(name:, pixel_id:, pixel_type:)
177
+ request(:post, "/api/v1/link/pixel", body: { name: name, pixel_id: pixel_id, pixel_type: pixel_type })
178
+ end
179
+
180
+ def list_pixels
181
+ request(:get, "/api/v1/link/pixel")
182
+ end
183
+
184
+ def get_pixel(id:)
185
+ request(:get, path_with_id("/api/v1/link/pixel/:id", id))
186
+ end
187
+
188
+ def update_pixel(id:, name: nil, pixel_id: nil, pixel_type: nil)
189
+ request(
190
+ :put,
191
+ path_with_id("/api/v1/link/pixel/:id", id),
192
+ body: {
193
+ id: id,
194
+ name: name,
195
+ pixel_id: pixel_id,
196
+ pixel_type: pixel_type
197
+ }
198
+ )
199
+ end
200
+
201
+ def delete_pixel(id:)
202
+ request(:delete, path_with_id("/api/v1/link/pixel/:id", id))
203
+ end
204
+
205
+ # QR Code Management
206
+ def get_qr_code(short_url:, output: nil, format: nil)
207
+ request(
208
+ :get,
209
+ "/api/v1/link/qr-code",
210
+ query: {
211
+ short_url: short_url,
212
+ output: output,
213
+ format: format
214
+ }
215
+ )
216
+ end
217
+
218
+ def update_qr_code(short_url:, image: nil, background_color: nil, corner_dots_color: nil, dots_color: nil,
219
+ dots_style: nil, corner_style: nil)
220
+ request(
221
+ :put,
222
+ "/api/v1/link/qr-code",
223
+ body: {
224
+ short_url: short_url,
225
+ image: image,
226
+ background_color: background_color,
227
+ corner_dots_color: corner_dots_color,
228
+ dots_color: dots_color,
229
+ dots_style: dots_style,
230
+ corner_style: corner_style
231
+ }
232
+ )
233
+ end
234
+
235
+ # Tag Management
236
+ def list_tags
237
+ request(:get, "/api/v1/link/tag")
238
+ end
239
+
240
+ def create_tag(tag:)
241
+ request(:post, "/api/v1/link/tag", body: { tag: tag })
242
+ end
243
+
244
+ def get_tag(id:)
245
+ request(:get, path_with_id("/api/v1/link/tag/:id", id))
246
+ end
247
+
248
+ def update_tag(id:, tag:)
249
+ request(:put, path_with_id("/api/v1/link/tag/:id", id), body: { tag: tag })
250
+ end
251
+
252
+ def delete_tag(id:)
253
+ request(:delete, path_with_id("/api/v1/link/tag/:id", id))
254
+ end
255
+
256
+ # Low-level request for endpoints added in the future.
257
+ def request(method, path, query: nil, body: nil, headers: {})
258
+ uri = build_uri(path, query)
259
+
260
+ req = build_request(method, uri)
261
+ default_headers.each { |key, value| req[key] = value }
262
+ headers.each { |key, value| req[key] = value }
263
+
264
+ if body
265
+ req["Content-Type"] ||= "application/json"
266
+ req.body = JSON.generate(compact_payload(body))
267
+ end
268
+
269
+ response = execute_request(uri, req)
270
+ raise_for_status!(response)
271
+ response
272
+ end
273
+
274
+ private
275
+
276
+ def default_user_agent
277
+ "tly-url-shortener-api/#{Tly::UrlShortenerApi::VERSION}"
278
+ end
279
+
280
+ def normalize_base_url(url)
281
+ normalized = url.to_s.strip.sub(%r{/+\z}, "")
282
+ raise ArgumentError, "base_url is required" if normalized.empty?
283
+
284
+ uri = URI.parse(normalized)
285
+ return normalized if uri.is_a?(URI::HTTP) && uri.host
286
+
287
+ raise ArgumentError, "base_url must be an absolute HTTP(S) URL"
288
+ rescue URI::InvalidURIError => e
289
+ raise ArgumentError, "base_url must be a valid URL: #{e.message}"
290
+ end
291
+
292
+ def default_headers
293
+ {
294
+ "Authorization" => "Bearer #{api_token}",
295
+ "Accept" => "application/json",
296
+ "User-Agent" => user_agent
297
+ }
298
+ end
299
+
300
+ def build_request(method, uri)
301
+ klass = {
302
+ get: Net::HTTP::Get,
303
+ post: Net::HTTP::Post,
304
+ put: Net::HTTP::Put,
305
+ patch: Net::HTTP::Patch,
306
+ delete: Net::HTTP::Delete
307
+ }[method.to_sym]
308
+
309
+ raise ArgumentError, "Unsupported HTTP method: #{method}" unless klass
310
+
311
+ klass.new(uri)
312
+ end
313
+
314
+ def execute_request(uri, request)
315
+ raw_response = Net::HTTP.start(
316
+ uri.host,
317
+ uri.port,
318
+ use_ssl: uri.scheme == "https",
319
+ open_timeout: open_timeout,
320
+ read_timeout: read_timeout
321
+ ) do |http|
322
+ http.request(request)
323
+ end
324
+
325
+ parsed = parse_response_body(raw_response)
326
+
327
+ Response.new(
328
+ status: raw_response.code.to_i,
329
+ headers: normalize_headers(raw_response),
330
+ body: parsed,
331
+ raw_body: raw_response.body.to_s
332
+ )
333
+ rescue IOError, EOFError, SocketError, SystemCallError, Timeout::Error => e
334
+ raise TransportError.new("T.LY API transport error: #{e.message}")
335
+ end
336
+
337
+ def parse_response_body(raw_response)
338
+ raw_body = raw_response.body.to_s
339
+ content_type = raw_response["Content-Type"].to_s.downcase
340
+
341
+ return raw_body unless content_type.include?("json")
342
+
343
+ JSON.parse(raw_body)
344
+ rescue JSON::ParserError
345
+ raw_body
346
+ end
347
+
348
+ def normalize_headers(raw_response)
349
+ raw_response.each_header.each_with_object({}) do |(key, value), headers|
350
+ headers[key.downcase] = value
351
+ end
352
+ end
353
+
354
+ def raise_for_status!(response)
355
+ return if response.success?
356
+
357
+ message = extract_error_message(response)
358
+ klass = error_class_for(response.status)
359
+ raise klass.new(message, status: response.status, response_body: response.body, headers: response.headers)
360
+ end
361
+
362
+ def extract_error_message(response)
363
+ body = response.body
364
+ return body["message"].to_s if body.is_a?(Hash) && body["message"]
365
+ return body["error"].to_s if body.is_a?(Hash) && body["error"]
366
+
367
+ "T.LY API request failed with status #{response.status}"
368
+ end
369
+
370
+ def error_class_for(status)
371
+ case status
372
+ when 400, 422 then ValidationError
373
+ when 401 then AuthenticationError
374
+ when 403 then PermissionError
375
+ when 404 then NotFoundError
376
+ when 429 then RateLimitError
377
+ when 400..499 then ClientError
378
+ else ServerError
379
+ end
380
+ end
381
+
382
+ def build_uri(path, query)
383
+ uri = URI.parse("#{base_url}#{path}")
384
+ query_string = build_query_string(query)
385
+ uri.query = query_string unless query_string.nil? || query_string.empty?
386
+ uri
387
+ end
388
+
389
+ def build_query_string(query)
390
+ return nil if query.nil?
391
+
392
+ pairs = []
393
+ flatten_query_value(query).each do |key, value|
394
+ next if value.nil?
395
+
396
+ pairs << "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
397
+ end
398
+ pairs.join("&")
399
+ end
400
+
401
+ def flatten_query_value(value, prefix = nil, output = [])
402
+ case value
403
+ when Hash
404
+ value.each do |key, nested_value|
405
+ nested_key = prefix ? "#{prefix}[#{key}]" : key.to_s
406
+ flatten_query_value(nested_value, nested_key, output)
407
+ end
408
+ when Array
409
+ value.each_with_index do |nested_value, index|
410
+ flatten_query_value(nested_value, "#{prefix}[#{index}]", output)
411
+ end
412
+ else
413
+ output << [prefix, value]
414
+ end
415
+ output
416
+ end
417
+
418
+ def compact_payload(value)
419
+ case value
420
+ when Hash
421
+ value.each_with_object({}) do |(key, nested_value), obj|
422
+ compacted = compact_payload(nested_value)
423
+ obj[key] = compacted unless compacted.nil?
424
+ end
425
+ when Array
426
+ value.map { |v| compact_payload(v) }.compact
427
+ else
428
+ value
429
+ end
430
+ end
431
+
432
+ def path_with_id(path, id)
433
+ path.sub(":id", escape_path_component(id))
434
+ end
435
+
436
+ def escape_path_component(component)
437
+ CGI.escape(component.to_s).gsub("+", "%20")
438
+ end
439
+ end
440
+ end
441
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tly
4
+ module UrlShortenerApi
5
+ class Error < StandardError
6
+ attr_reader :status, :response_body, :headers
7
+
8
+ def initialize(message = "T.LY API request failed", status: nil, response_body: nil, headers: nil)
9
+ super(message)
10
+ @status = status
11
+ @response_body = response_body
12
+ @headers = headers || {}
13
+ end
14
+ end
15
+
16
+ class ClientError < Error; end
17
+ class AuthenticationError < ClientError; end
18
+ class PermissionError < ClientError; end
19
+ class NotFoundError < ClientError; end
20
+ class ValidationError < ClientError; end
21
+ class RateLimitError < ClientError; end
22
+ class ServerError < Error; end
23
+ class TransportError < Error; end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tly
4
+ module UrlShortenerApi
5
+ class Response
6
+ attr_reader :status, :headers, :body, :raw_body
7
+
8
+ def initialize(status:, headers:, body:, raw_body:)
9
+ @status = status
10
+ @headers = headers
11
+ @body = body
12
+ @raw_body = raw_body
13
+ end
14
+
15
+ def success?
16
+ (200..299).cover?(status)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tly
4
+ module UrlShortenerApi
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tly/url_shortener_api/version"
4
+ require "tly/url_shortener_api/errors"
5
+ require "tly/url_shortener_api/response"
6
+ require "tly/url_shortener_api/client"
7
+
8
+ module Tly
9
+ module UrlShortenerApi
10
+ class << self
11
+ def client(api_token:, **options)
12
+ Client.new(api_token: api_token, **options)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tly/url_shortener_api"
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class ClientTest < Minitest::Test
6
+ def test_shorten_link_sends_bearer_and_json
7
+ with_server do |server|
8
+ client = build_client(server)
9
+
10
+ response = client.shorten_link(
11
+ long_url: "https://example.com/long/path",
12
+ description: "Example link"
13
+ )
14
+
15
+ request = server.requests.last
16
+ body = JSON.parse(request.body)
17
+
18
+ assert_equal 200, response.status
19
+ assert_equal true, response.body["ok"]
20
+ assert_equal "Bearer test_token", request.headers["authorization"]
21
+ assert_equal "application/json", request.headers["accept"]
22
+ assert_equal "https://example.com/long/path", body["long_url"]
23
+ assert_equal "Example link", body["description"]
24
+ refute body.key?("domain")
25
+ end
26
+ end
27
+
28
+ def test_list_links_serializes_indexed_array_query
29
+ with_server do |server|
30
+ client = build_client(server)
31
+
32
+ client.list_links(
33
+ tag_ids: [1, 2],
34
+ pixel_ids: [10],
35
+ domains: ["t.ly", "my.t.ly"]
36
+ )
37
+
38
+ query_string = server.requests.last.query_string
39
+
40
+ assert_includes query_string, "tag_ids%5B0%5D=1"
41
+ assert_includes query_string, "tag_ids%5B1%5D=2"
42
+ assert_includes query_string, "pixel_ids%5B0%5D=10"
43
+ assert_includes query_string, "domains%5B0%5D=t.ly"
44
+ assert_includes query_string, "domains%5B1%5D=my.t.ly"
45
+ end
46
+ end
47
+
48
+ def test_non_json_response_body_is_returned_raw
49
+ with_server(content_type: "image/png", body: "PNG_BINARY".b) do |server|
50
+ client = build_client(server)
51
+
52
+ response = client.get_qr_code(short_url: "https://t.ly/abc")
53
+
54
+ assert_equal "PNG_BINARY".b, response.body
55
+ assert_equal "PNG_BINARY".b, response.raw_body
56
+ assert_equal true, response.success?
57
+ end
58
+ end
59
+
60
+ def test_raises_typed_error_for_unauthorized
61
+ with_server(status: 401, body: JSON.generate({ message: "Unauthorized" })) do |server|
62
+ client = build_client(server)
63
+
64
+ error = assert_raises(Tly::UrlShortenerApi::AuthenticationError) do
65
+ client.get_link(short_url: "https://t.ly/missing")
66
+ end
67
+
68
+ assert_equal 401, error.status
69
+ assert_equal "Unauthorized", error.message
70
+ end
71
+ end
72
+
73
+ def test_parses_vendor_json_content_types
74
+ with_server(content_type: "application/problem+json", body: JSON.generate({ error: "problem" })) do |server|
75
+ client = build_client(server)
76
+ response = client.get_link(short_url: "https://t.ly/abc")
77
+
78
+ assert_equal({ "error" => "problem" }, response.body)
79
+ end
80
+ end
81
+
82
+ def test_raises_transport_error_for_network_failures
83
+ client = Tly::UrlShortenerApi::Client.new(
84
+ api_token: "test_token",
85
+ base_url: "https://api.t.ly"
86
+ )
87
+
88
+ Net::HTTP.stub(:start, proc { raise Errno::ECONNREFUSED, "Connection refused" }) do
89
+ error = assert_raises(Tly::UrlShortenerApi::TransportError) do
90
+ client.get_link(short_url: "https://t.ly/abc")
91
+ end
92
+
93
+ assert_includes error.message, "transport error"
94
+ end
95
+ end
96
+
97
+ def test_rejects_invalid_base_url
98
+ error = assert_raises(ArgumentError) do
99
+ Tly::UrlShortenerApi::Client.new(api_token: "token", base_url: "not-a-url")
100
+ end
101
+
102
+ assert_includes error.message, "absolute HTTP(S) URL"
103
+ end
104
+
105
+ private
106
+
107
+ def build_client(server)
108
+ Tly::UrlShortenerApi::Client.new(
109
+ api_token: "test_token",
110
+ base_url: "http://127.0.0.1:#{server.port}"
111
+ )
112
+ end
113
+
114
+ def with_server(status: 200, content_type: "application/json", body: JSON.generate({ ok: true }), &block)
115
+ server = TestHttpServer.new do |_req, res|
116
+ res.status = status
117
+ res["Content-Type"] = content_type
118
+ res.body = body
119
+ end
120
+
121
+ server.start
122
+ yield(server)
123
+ ensure
124
+ server&.stop
125
+ end
126
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "webrick"
5
+ require "json"
6
+
7
+ require "tly_url_shortener_api"
8
+
9
+ class TestHttpServer
10
+ RequestCapture = Struct.new(
11
+ :request_method,
12
+ :path,
13
+ :query_string,
14
+ :headers,
15
+ :body,
16
+ keyword_init: true
17
+ )
18
+
19
+ attr_reader :port, :requests
20
+
21
+ def initialize(&handler)
22
+ @handler = handler
23
+ @requests = []
24
+ @server = WEBrick::HTTPServer.new(
25
+ Port: 0,
26
+ Logger: WEBrick::Log.new(File::NULL),
27
+ AccessLog: []
28
+ )
29
+
30
+ @server.mount_proc("/") do |req, res|
31
+ @requests << RequestCapture.new(
32
+ request_method: req.request_method,
33
+ path: req.path,
34
+ query_string: req.query_string,
35
+ headers: req.header.transform_values { |v| v.is_a?(Array) ? v.first : v },
36
+ body: req.body
37
+ )
38
+ @handler.call(req, res)
39
+ end
40
+ end
41
+
42
+ def start
43
+ @thread = Thread.new { @server.start }
44
+ @port = @server.config[:Port]
45
+ sleep 0.05
46
+ end
47
+
48
+ def stop
49
+ @server.shutdown
50
+ @thread.join if @thread
51
+ end
52
+ end
metadata ADDED
@@ -0,0 +1,87 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tly-url-shortener-api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - T.LY
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-02-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: Ruby client for creating, managing, and analyzing short links with the
42
+ T.LY API.
43
+ email:
44
+ - support@t.ly
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE.txt
51
+ - README.md
52
+ - Rakefile
53
+ - lib/tly/url_shortener_api.rb
54
+ - lib/tly/url_shortener_api/client.rb
55
+ - lib/tly/url_shortener_api/errors.rb
56
+ - lib/tly/url_shortener_api/response.rb
57
+ - lib/tly/url_shortener_api/version.rb
58
+ - lib/tly_url_shortener_api.rb
59
+ - test/client_test.rb
60
+ - test/test_helper.rb
61
+ homepage: https://t.ly
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ homepage_uri: https://t.ly
66
+ source_code_uri: https://github.com/tly/url-shortener-ruby
67
+ changelog_uri: https://github.com/tly/url-shortener-ruby/blob/main/CHANGELOG.md
68
+ post_install_message:
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '2.7'
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 3.0.3.1
84
+ signing_key:
85
+ specification_version: 4
86
+ summary: Official Ruby client for the T.LY URL Shortener API
87
+ test_files: []