sink-cool 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +102 -13
- data/lib/sink/client.rb +104 -19
- data/lib/sink/error.rb +32 -1
- data/lib/sink/keys.rb +30 -0
- data/lib/sink/link.rb +76 -0
- data/lib/sink/page.rb +32 -0
- data/lib/sink/tag.rb +13 -0
- data/lib/sink/version.rb +1 -1
- data/lib/sink.rb +4 -0
- metadata +33 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2a7189eda4d5a284c80c198aeb86cfffb50a055a8ad3c9e2eec91b2dd9560f1c
|
|
4
|
+
data.tar.gz: 5cbd0afad6e397979edeeb86f80d29f7bb85d17474163d5ffc60283a527b32ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ba8fd2a4f777dcbc73d70692e920627500fd6beea73fa9a1034fc4d714e97594118b254fd8d877f8c349755cedb98fcd7dbf15995e40bee0ee6b73fdd7aab92
|
|
7
|
+
data.tar.gz: f195221d617d7135ac222079426989d48ebaa58ea98c83b0b77abc7dadc79b39d6de6717bc9692ed65aedfa1f3686ad6f99ac9f84c60c395656617005f4a7682
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.2.0 - 2026-07-24
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `Sink::Link` value objects with snake_case readers, `Time` timestamps, predicate methods, `to_h`, and `[]` access to fields without a reader.
|
|
12
|
+
- `Sink::Page` collections including `Enumerable`, exposing `cursor`, `complete?`, and `more?`.
|
|
13
|
+
- `Sink::Tag` records with `name` and `count`.
|
|
14
|
+
- `Client#each_link` to iterate every page without handling cursors manually.
|
|
15
|
+
- Error subclasses for each API failure mode: `Sink::ValidationError`, `Unauthorized`, `Forbidden`, `NotFound`, `Conflict`, `StorageNotReady`, `RateLimited`, `ServerError`, `ConnectionError`, and `TimeoutError`.
|
|
16
|
+
- `Sink::Error#data` for the validation details returned by the API.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **Breaking:** link endpoints return `Sink::Link` and `Sink::Page` objects instead of raw hashes and arrays. Call `to_h` for the previous key names in snake_case.
|
|
21
|
+
- **Breaking:** link attributes are passed as snake_case keyword arguments and camelized for the API, so `redirectWithQuery` becomes `redirect_with_query`. Unknown attributes now raise `ArgumentError`.
|
|
22
|
+
- **Breaking:** `create_link`, `edit_link`, and `upsert_link` take keyword arguments, with `url` required by all three and `slug` also required by `edit_link`.
|
|
23
|
+
- **Breaking:** `delete_link` returns `true` instead of `nil`.
|
|
24
|
+
- **Breaking:** `verify` returns a hash with snake_case symbol keys.
|
|
25
|
+
- `expires_at` accepts a `Time`, a `Date`, or a unix timestamp and is sent as `expiration`.
|
|
26
|
+
- Timeouts, TLS, and socket failures raise `Sink::Error` subclasses instead of leaking `Net::HTTP` exceptions.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Skip blank `message` values when building error messages, so the Sink API `statusMessage` is no longer replaced by an empty string.
|
|
31
|
+
- Read `statusText` as an error message source, matching newer Sink releases.
|
|
32
|
+
- Validate the token before the base URL so an empty token reports the right argument.
|
|
33
|
+
|
|
7
34
|
## 0.1.2 - 2026-07-23
|
|
8
35
|
|
|
9
36
|
### Fixed
|
data/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# sink-cool
|
|
2
2
|
|
|
3
|
+
[](https://github.com/7a6163/sink-cool/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/sink-cool)
|
|
5
|
+
[](https://codecov.io/gh/7a6163/sink-cool)
|
|
6
|
+
|
|
3
7
|
Ruby client for the [Sink](https://sink.cool/) URL shortener API.
|
|
4
8
|
|
|
5
9
|
Requires Ruby 3.3 or newer.
|
|
@@ -59,19 +63,17 @@ client = Sink::Client.new(
|
|
|
59
63
|
client = Sink.client
|
|
60
64
|
|
|
61
65
|
client.verify
|
|
66
|
+
# => { name: "Sink", auth_method: "site-token", user_email: "admin@example.com", ... }
|
|
62
67
|
|
|
63
68
|
client.create_link(
|
|
64
69
|
url: "https://example.com/articles/1",
|
|
65
70
|
slug: "article-1",
|
|
66
|
-
tags: ["articles"]
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
client.edit_link(
|
|
70
|
-
url: "https://example.com/articles/2",
|
|
71
|
-
slug: "article-1",
|
|
72
|
-
tags: ["articles"]
|
|
71
|
+
tags: ["articles"],
|
|
72
|
+
redirect_with_query: true,
|
|
73
|
+
expires_at: 1.week.from_now
|
|
73
74
|
)
|
|
74
75
|
|
|
76
|
+
client.edit_link(url: "https://example.com/articles/2", slug: "article-1")
|
|
75
77
|
client.upsert_link(url: "https://example.com", slug: "example")
|
|
76
78
|
client.link("example")
|
|
77
79
|
client.links(limit: 20, sort: "newest", status: "active")
|
|
@@ -81,20 +83,107 @@ client.check_links(limit: 6, timeout: 6)
|
|
|
81
83
|
client.delete_link("example")
|
|
82
84
|
```
|
|
83
85
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
Attributes are snake_case and converted to the camelCase names the API expects,
|
|
87
|
+
so `redirect_with_query` is sent as `redirectWithQuery`. Unknown attributes
|
|
88
|
+
raise `ArgumentError` instead of being silently dropped by the server.
|
|
89
|
+
`expires_at` accepts a `Time`, a `Date`, or a unix timestamp.
|
|
90
|
+
|
|
91
|
+
### Links
|
|
92
|
+
|
|
93
|
+
`create_link`, `edit_link`, `upsert_link`, and `link` all return a `Sink::Link`:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
link = client.create_link(url: "https://example.com", slug: "example")
|
|
97
|
+
|
|
98
|
+
link.slug # => "example"
|
|
99
|
+
link.short_link # => "https://sink.example.com/example"
|
|
100
|
+
link.created_at # => 2026-07-24 12:00:00 UTC
|
|
101
|
+
link.expires_at # => Time or nil
|
|
102
|
+
link.expired? # => false
|
|
103
|
+
link.cloaking? # => false
|
|
104
|
+
link.redirect_with_query? # => true
|
|
105
|
+
link.tags # => ["articles"]
|
|
106
|
+
link.geo # => { "US" => "https://example.com/us" }
|
|
107
|
+
link.to_h # => { slug: "example", url: "https://example.com", ... }
|
|
108
|
+
link[:any_future_field] # raw access to fields without a reader
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`link(slug)` responses carry no short URL, so `short_link` is `nil` there.
|
|
112
|
+
|
|
113
|
+
`upsert_link` never overwrites an existing slug, so check which happened:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
link = client.upsert_link(url: "https://example.com", slug: "example")
|
|
117
|
+
|
|
118
|
+
link.created? # => true when the link was stored
|
|
119
|
+
link.existing? # => true when the slug already pointed somewhere else
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`delete_link` returns `true`.
|
|
123
|
+
|
|
124
|
+
### Collections
|
|
125
|
+
|
|
126
|
+
`links`, `search_links`, and `check_links` return a `Sink::Page`, which is
|
|
127
|
+
`Enumerable`:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
page = client.links(limit: 20)
|
|
131
|
+
|
|
132
|
+
page.map(&:slug)
|
|
133
|
+
page.cursor # => "..." pagination cursor
|
|
134
|
+
page.complete? # => false when more pages are available
|
|
135
|
+
page.more? # => true when a following page can be fetched
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Use `each_link` to walk every page without handling cursors:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
client.each_link(tag: "articles") { |link| puts link.short_link }
|
|
142
|
+
client.each_link.lazy.select(&:expired?).first(10)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`tags` returns `Sink::Tag` records with `name` and `count`.
|
|
86
146
|
|
|
87
147
|
## Errors
|
|
88
148
|
|
|
89
|
-
Non-successful responses raise `Sink::Error
|
|
149
|
+
Non-successful responses raise a subclass of `Sink::Error`, so a Rails
|
|
150
|
+
controller can rescue the cases it cares about:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
class LinksController < ApplicationController
|
|
154
|
+
rescue_from Sink::NotFound, with: :not_found
|
|
155
|
+
|
|
156
|
+
def create
|
|
157
|
+
link = Sink.client.create_link(url: params[:url], slug: params[:slug])
|
|
158
|
+
render json: link.to_h, status: :created
|
|
159
|
+
rescue Sink::Conflict
|
|
160
|
+
render json: { error: "slug already taken" }, status: :conflict
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
| Class | Raised for |
|
|
166
|
+
| --- | --- |
|
|
167
|
+
| `Sink::ValidationError` | 400, 422 (`error.data` holds the API details) |
|
|
168
|
+
| `Sink::Unauthorized` | 401, usually a wrong site token |
|
|
169
|
+
| `Sink::Forbidden` | 403, including preview mode |
|
|
170
|
+
| `Sink::NotFound` | 404 |
|
|
171
|
+
| `Sink::Conflict` | 409, the slug already exists |
|
|
172
|
+
| `Sink::StorageNotReady` | 423, open Dashboard → Links once after deploying |
|
|
173
|
+
| `Sink::RateLimited` | 429 |
|
|
174
|
+
| `Sink::ServerError` | 5xx |
|
|
175
|
+
| `Sink::TimeoutError` | open or read timeout |
|
|
176
|
+
| `Sink::ConnectionError` | DNS, TLS, and socket failures |
|
|
177
|
+
|
|
178
|
+
Every error exposes `status`, `message`, `body`, and `data`:
|
|
90
179
|
|
|
91
180
|
```ruby
|
|
92
181
|
begin
|
|
93
182
|
Sink.client.link("missing")
|
|
94
183
|
rescue Sink::Error => error
|
|
95
|
-
error.status
|
|
96
|
-
error.message
|
|
97
|
-
error.body
|
|
184
|
+
error.status # => 404
|
|
185
|
+
error.message # => "Not Found"
|
|
186
|
+
error.body # => parsed response body
|
|
98
187
|
end
|
|
99
188
|
```
|
|
100
189
|
|
data/lib/sink/client.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "net/http"
|
|
5
|
+
require "openssl"
|
|
5
6
|
require "uri"
|
|
6
7
|
|
|
7
8
|
module Sink
|
|
@@ -12,48 +13,119 @@ module Sink
|
|
|
12
13
|
put: Net::HTTP::Put
|
|
13
14
|
}.freeze
|
|
14
15
|
|
|
16
|
+
LINK_ATTRIBUTES = %i[
|
|
17
|
+
url slug comment expiration expires_at title description image apple google
|
|
18
|
+
cloaking redirect_with_query password unsafe geo tags
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error].freeze
|
|
22
|
+
CONNECTION_ERRORS = [
|
|
23
|
+
IOError, SocketError, SystemCallError, Net::ProtocolError, OpenSSL::SSL::SSLError
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
15
26
|
def initialize(base_url:, token:, open_timeout: 5, read_timeout: 30)
|
|
16
|
-
@base_url = validate_base_url(base_url)
|
|
17
27
|
@token = token.to_s
|
|
28
|
+
raise ArgumentError, "token is required" if @token.empty?
|
|
29
|
+
|
|
30
|
+
@base_url = validate_base_url(base_url)
|
|
18
31
|
@open_timeout = open_timeout
|
|
19
32
|
@read_timeout = read_timeout
|
|
20
|
-
raise ArgumentError, "token is required" if @token.empty?
|
|
21
33
|
end
|
|
22
34
|
|
|
23
|
-
def verify = request(:get, "/api/verify")
|
|
35
|
+
def verify = Keys.underscore_keys(request(:get, "/api/verify") || {})
|
|
24
36
|
|
|
25
|
-
def create_link(
|
|
37
|
+
def create_link(url:, **attributes)
|
|
38
|
+
Link.from_response(request(:post, "/api/link/create", body: link_payload(attributes.merge(url: url))))
|
|
39
|
+
end
|
|
26
40
|
|
|
27
|
-
def edit_link(
|
|
41
|
+
def edit_link(url:, slug:, **attributes)
|
|
42
|
+
Link.from_response(request(:put, "/api/link/edit", body: link_payload(attributes.merge(url: url, slug: slug))))
|
|
43
|
+
end
|
|
28
44
|
|
|
29
|
-
def upsert_link(
|
|
45
|
+
def upsert_link(url:, **attributes)
|
|
46
|
+
Link.from_response(request(:post, "/api/link/upsert", body: link_payload(attributes.merge(url: url))))
|
|
47
|
+
end
|
|
30
48
|
|
|
31
|
-
def delete_link(slug)
|
|
49
|
+
def delete_link(slug)
|
|
50
|
+
request(:post, "/api/link/delete", body: { "slug" => slug })
|
|
51
|
+
true
|
|
52
|
+
end
|
|
32
53
|
|
|
33
|
-
def link(slug) = request(:get, "/api/link/query", query: { slug: slug })
|
|
54
|
+
def link(slug) = Link.from_response(request(:get, "/api/link/query", query: { slug: slug }))
|
|
34
55
|
|
|
35
56
|
def links(limit: nil, cursor: nil, sort: nil, tag: nil, status: nil)
|
|
36
|
-
request(:get, "/api/link/list", query: {
|
|
57
|
+
body = request(:get, "/api/link/list", query: {
|
|
37
58
|
limit: limit, cursor: cursor, sort: sort, tag: tag, status: status
|
|
38
59
|
})
|
|
60
|
+
|
|
61
|
+
link_page(body)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def each_link(**options)
|
|
65
|
+
return enum_for(:each_link, **options) unless block_given?
|
|
66
|
+
|
|
67
|
+
cursor = nil
|
|
68
|
+
loop do
|
|
69
|
+
page = links(**options, cursor: cursor)
|
|
70
|
+
page.each { |link| yield link }
|
|
71
|
+
break unless page.more?
|
|
72
|
+
|
|
73
|
+
cursor = page.cursor
|
|
74
|
+
end
|
|
39
75
|
end
|
|
40
76
|
|
|
41
77
|
def search_links(query: nil, url: nil, limit: nil, tag: nil, status: nil)
|
|
42
|
-
request(:get, "/api/link/search", query: {
|
|
78
|
+
body = request(:get, "/api/link/search", query: {
|
|
43
79
|
q: query, url: url, limit: limit, tag: tag, status: status
|
|
44
80
|
})
|
|
81
|
+
|
|
82
|
+
link_page(body)
|
|
45
83
|
end
|
|
46
84
|
|
|
47
|
-
def tags
|
|
85
|
+
def tags
|
|
86
|
+
Array(request(:get, "/api/link/tags")).map { |tag| Tag.from_response(tag) }
|
|
87
|
+
end
|
|
48
88
|
|
|
49
89
|
def check_links(cursor: nil, limit: 6, timeout: 6)
|
|
50
|
-
request(:post, "/api/link/check", body: {
|
|
51
|
-
cursor
|
|
90
|
+
body = request(:post, "/api/link/check", body: {
|
|
91
|
+
"cursor" => cursor, "limit" => limit, "timeout" => timeout
|
|
52
92
|
}.compact)
|
|
93
|
+
|
|
94
|
+
page(body, "results") { |result| Keys.underscore_keys(result) }
|
|
53
95
|
end
|
|
54
96
|
|
|
55
97
|
private
|
|
56
98
|
|
|
99
|
+
def link_payload(attributes)
|
|
100
|
+
unknown = attributes.keys - LINK_ATTRIBUTES
|
|
101
|
+
raise ArgumentError, "unknown link attributes: #{unknown.join(", ")}" if unknown.any?
|
|
102
|
+
|
|
103
|
+
payload = attributes.compact
|
|
104
|
+
expires_at = payload.delete(:expires_at)
|
|
105
|
+
payload[:expiration] = unix_timestamp(expires_at) unless expires_at.nil?
|
|
106
|
+
|
|
107
|
+
Keys.camelize_keys(payload)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def unix_timestamp(value)
|
|
111
|
+
return value.to_i if value.is_a?(Numeric)
|
|
112
|
+
return value.to_time.to_i if value.respond_to?(:to_time)
|
|
113
|
+
|
|
114
|
+
raise ArgumentError, "expires_at must be a Time or a unix timestamp"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def link_page(body) = page(body, "links") { |link| Link.from_response(link) }
|
|
118
|
+
|
|
119
|
+
def page(body, key, &build)
|
|
120
|
+
records, cursor, complete = case body
|
|
121
|
+
when Array then [body, nil, true]
|
|
122
|
+
when Hash then [Array(body[key]), body["cursor"], body.fetch("list_complete", true)]
|
|
123
|
+
else [[], nil, true]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
Page.new(records: records.map(&build), cursor: cursor, complete: complete)
|
|
127
|
+
end
|
|
128
|
+
|
|
57
129
|
def request(method, path, query: nil, body: nil)
|
|
58
130
|
uri = URI("#{@base_url}#{path}")
|
|
59
131
|
params = query&.compact
|
|
@@ -69,7 +141,15 @@ module Sink
|
|
|
69
141
|
http_request = REQUEST_CLASSES.fetch(method).new(uri, headers)
|
|
70
142
|
http_request.body = JSON.generate(body) if body
|
|
71
143
|
|
|
72
|
-
parse_response(
|
|
144
|
+
parse_response(dispatch(uri, http_request))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def dispatch(uri, http_request)
|
|
148
|
+
perform_request(uri, http_request)
|
|
149
|
+
rescue *TIMEOUT_ERRORS => error
|
|
150
|
+
raise TimeoutError.new("#{uri.host} timed out: #{error.message}")
|
|
151
|
+
rescue *CONNECTION_ERRORS => error
|
|
152
|
+
raise ConnectionError.new("#{uri.host} is unreachable: #{error.message}")
|
|
73
153
|
end
|
|
74
154
|
|
|
75
155
|
def perform_request(uri, request)
|
|
@@ -88,11 +168,16 @@ module Sink
|
|
|
88
168
|
body = parse_body(response.body)
|
|
89
169
|
return body if response.is_a?(Net::HTTPSuccess)
|
|
90
170
|
|
|
91
|
-
message =
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
171
|
+
message = error_message(body) || "HTTP #{response.code} #{response.message}"
|
|
172
|
+
raise Error.for(status: response.code.to_i, message: message, body: body)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def error_message(body)
|
|
176
|
+
return nil unless body.is_a?(Hash)
|
|
177
|
+
|
|
178
|
+
%w[message statusMessage statusText].filter_map { |key| body[key] }
|
|
179
|
+
.map { |value| value.to_s.strip }
|
|
180
|
+
.find { |value| !value.empty? }
|
|
96
181
|
end
|
|
97
182
|
|
|
98
183
|
def parse_body(body)
|
data/lib/sink/error.rb
CHANGED
|
@@ -4,10 +4,41 @@ module Sink
|
|
|
4
4
|
class Error < StandardError
|
|
5
5
|
attr_reader :status, :body
|
|
6
6
|
|
|
7
|
-
def
|
|
7
|
+
def self.for(status:, message:, body: nil)
|
|
8
|
+
klass = STATUS_ERRORS[status] || (status.to_i >= 500 ? ServerError : self)
|
|
9
|
+
klass.new(message, status: status, body: body)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(message = nil, status: nil, body: nil)
|
|
8
13
|
@status = status
|
|
9
14
|
@body = body
|
|
10
15
|
super(message)
|
|
11
16
|
end
|
|
17
|
+
|
|
18
|
+
def data = body.is_a?(Hash) ? body["data"] : nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class ValidationError < Error; end
|
|
22
|
+
class Unauthorized < Error; end
|
|
23
|
+
class Forbidden < Error; end
|
|
24
|
+
class NotFound < Error; end
|
|
25
|
+
class Conflict < Error; end
|
|
26
|
+
class StorageNotReady < Error; end
|
|
27
|
+
class RateLimited < Error; end
|
|
28
|
+
class ServerError < Error; end
|
|
29
|
+
class ConnectionError < Error; end
|
|
30
|
+
class TimeoutError < ConnectionError; end
|
|
31
|
+
|
|
32
|
+
class Error
|
|
33
|
+
STATUS_ERRORS = {
|
|
34
|
+
400 => ValidationError,
|
|
35
|
+
401 => Unauthorized,
|
|
36
|
+
403 => Forbidden,
|
|
37
|
+
404 => NotFound,
|
|
38
|
+
409 => Conflict,
|
|
39
|
+
422 => ValidationError,
|
|
40
|
+
423 => StorageNotReady,
|
|
41
|
+
429 => RateLimited
|
|
42
|
+
}.freeze
|
|
12
43
|
end
|
|
13
44
|
end
|
data/lib/sink/keys.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sink
|
|
4
|
+
module Keys
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def underscore(key)
|
|
8
|
+
key.to_s
|
|
9
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
10
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
11
|
+
.downcase
|
|
12
|
+
.to_sym
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def camelize(key)
|
|
16
|
+
head, *tail = key.to_s.split("_")
|
|
17
|
+
[head, *tail.map(&:capitalize)].join
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Sink payloads are flat, so nested hashes such as `geo` keep their original
|
|
21
|
+
# keys (country codes) instead of being converted to snake_case.
|
|
22
|
+
def underscore_keys(hash)
|
|
23
|
+
hash.to_h { |key, value| [underscore(key), value] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def camelize_keys(hash)
|
|
27
|
+
hash.to_h { |key, value| [camelize(key), value] }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/sink/link.rb
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sink
|
|
4
|
+
class Link
|
|
5
|
+
ATTRIBUTES = %i[
|
|
6
|
+
id url slug comment title description image apple google password
|
|
7
|
+
cloaking redirect_with_query unsafe geo tags expiration
|
|
8
|
+
created_at updated_at short_link status
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :attributes
|
|
12
|
+
|
|
13
|
+
def self.from_response(body)
|
|
14
|
+
return nil unless body.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
link = body["link"].is_a?(Hash) ? body["link"] : body
|
|
17
|
+
attributes = Keys.underscore_keys(link)
|
|
18
|
+
attributes[:short_link] = body["shortLink"] if body.key?("shortLink")
|
|
19
|
+
attributes[:status] = body["status"] if body.key?("status")
|
|
20
|
+
|
|
21
|
+
new(attributes)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(attributes)
|
|
25
|
+
@attributes = attributes.freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
%i[id url slug comment title description image apple google password short_link status].each do |name|
|
|
29
|
+
define_method(name) { @attributes[name] }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tags = @attributes[:tags] || []
|
|
33
|
+
|
|
34
|
+
def geo = @attributes[:geo] || {}
|
|
35
|
+
|
|
36
|
+
def expiration = @attributes[:expiration]
|
|
37
|
+
|
|
38
|
+
def created_at = time(:created_at)
|
|
39
|
+
|
|
40
|
+
def updated_at = time(:updated_at)
|
|
41
|
+
|
|
42
|
+
def expires_at = time(:expiration)
|
|
43
|
+
|
|
44
|
+
def cloaking? = !!@attributes[:cloaking]
|
|
45
|
+
|
|
46
|
+
def unsafe? = !!@attributes[:unsafe]
|
|
47
|
+
|
|
48
|
+
def redirect_with_query? = !!@attributes[:redirect_with_query]
|
|
49
|
+
|
|
50
|
+
def expired? = !expiration.nil? && expiration <= Time.now.to_i
|
|
51
|
+
|
|
52
|
+
def created? = status == "created"
|
|
53
|
+
|
|
54
|
+
def existing? = status == "existing"
|
|
55
|
+
|
|
56
|
+
def [](key) = @attributes[key.to_sym]
|
|
57
|
+
|
|
58
|
+
def to_h = @attributes.dup
|
|
59
|
+
|
|
60
|
+
def as_json(*) = to_h
|
|
61
|
+
|
|
62
|
+
def ==(other) = other.is_a?(Link) && other.attributes == @attributes
|
|
63
|
+
alias eql? ==
|
|
64
|
+
|
|
65
|
+
def hash = @attributes.hash
|
|
66
|
+
|
|
67
|
+
def inspect = "#<#{self.class} slug=#{slug.inspect} url=#{url.inspect}>"
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def time(key)
|
|
72
|
+
value = @attributes[key]
|
|
73
|
+
Time.at(value).utc if value
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/sink/page.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sink
|
|
4
|
+
class Page
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
attr_reader :records, :cursor
|
|
8
|
+
|
|
9
|
+
def initialize(records:, cursor: nil, complete: true)
|
|
10
|
+
@records = records.freeze
|
|
11
|
+
@cursor = cursor
|
|
12
|
+
@complete = complete
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def each(&) = @records.each(&)
|
|
16
|
+
|
|
17
|
+
def complete? = !!@complete
|
|
18
|
+
|
|
19
|
+
def more? = !complete? && !cursor.nil?
|
|
20
|
+
|
|
21
|
+
def size = @records.size
|
|
22
|
+
alias length size
|
|
23
|
+
|
|
24
|
+
def empty? = @records.empty?
|
|
25
|
+
|
|
26
|
+
def [](index) = @records[index]
|
|
27
|
+
|
|
28
|
+
def to_a = @records.dup
|
|
29
|
+
|
|
30
|
+
def inspect = "#<#{self.class} size=#{size} cursor=#{cursor.inspect} complete=#{complete?}>"
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/sink/tag.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sink
|
|
4
|
+
Tag = Data.define(:name, :count) do
|
|
5
|
+
def self.from_response(body)
|
|
6
|
+
return new(name: body.to_s, count: nil) unless body.is_a?(Hash)
|
|
7
|
+
|
|
8
|
+
new(name: body["name"], count: body["count"])
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_s = name.to_s
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/sink/version.rb
CHANGED
data/lib/sink.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sink-cool
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sink Ruby contributors
|
|
@@ -37,6 +37,34 @@ dependencies:
|
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '13.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: simplecov
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.22'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.22'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: simplecov_json_formatter
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0.1'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0.1'
|
|
40
68
|
description: A lightweight Ruby client for managing links with the Sink URL shortener
|
|
41
69
|
API.
|
|
42
70
|
executables: []
|
|
@@ -50,6 +78,10 @@ files:
|
|
|
50
78
|
- lib/sink.rb
|
|
51
79
|
- lib/sink/client.rb
|
|
52
80
|
- lib/sink/error.rb
|
|
81
|
+
- lib/sink/keys.rb
|
|
82
|
+
- lib/sink/link.rb
|
|
83
|
+
- lib/sink/page.rb
|
|
84
|
+
- lib/sink/tag.rb
|
|
53
85
|
- lib/sink/version.rb
|
|
54
86
|
homepage: https://docs.sink.cool/api/
|
|
55
87
|
licenses:
|