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 +7 -0
- data/CHANGELOG.md +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +172 -0
- data/Rakefile +11 -0
- data/lib/tly/url_shortener_api/client.rb +441 -0
- data/lib/tly/url_shortener_api/errors.rb +25 -0
- data/lib/tly/url_shortener_api/response.rb +20 -0
- data/lib/tly/url_shortener_api/version.rb +7 -0
- data/lib/tly/url_shortener_api.rb +16 -0
- data/lib/tly_url_shortener_api.rb +3 -0
- data/test/client_test.rb +126 -0
- data/test/test_helper.rb +52 -0
- metadata +87 -0
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
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,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,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
|
data/test/client_test.rb
ADDED
|
@@ -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
|
data/test/test_helper.rb
ADDED
|
@@ -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: []
|