sink-cool

CI Gem Version codecov

Ruby client for the Sink URL shortener API.

Requires Ruby 3.3 or newer.

Installation

Add the gem to your bundle:

bundle add sink-cool

Or install it directly:

gem install sink-cool

Configuration

Create an initializer such as config/initializers/sink.rb:

require "sink-cool"

Sink.configure do |config|
  config.base_url = ENV.fetch("SINK_BASE_URL")
  config.token = ENV.fetch("SINK_TOKEN")
end

Use HTTPS in production so the bearer token is encrypted in transit.

Optional HTTP timeouts:

Sink.configure do |config|
  config.base_url = ENV.fetch("SINK_BASE_URL")
  config.token = ENV.fetch("SINK_TOKEN")
  config.open_timeout = 5
  config.read_timeout = 30
end

You can also create an independent client:

client = Sink::Client.new(
  base_url: "https://sink.example.com",
  token: "site-token"
)

Usage

client = Sink.client

client.verify
# => { name: "Sink", auth_method: "site-token", user_email: "admin@example.com", ... }

client.create_link(
  url: "https://example.com/articles/1",
  slug: "article-1",
  tags: ["articles"],
  redirect_with_query: true,
  expires_at: 1.week.from_now
)

client.edit_link(url: "https://example.com/articles/2", slug: "article-1")
client.upsert_link(url: "https://example.com", slug: "example")
client.link("example")
client.links(limit: 20, sort: "newest", status: "active")
client.search_links(query: "example", tag: "articles")
client.tags
client.check_links(limit: 6, timeout: 6)
client.delete_link("example")

Attributes are snake_case and converted to the camelCase names the API expects, so redirect_with_query is sent as redirectWithQuery. Unknown attributes raise ArgumentError instead of being silently dropped by the server. expires_at accepts a Time, a Date, or a unix timestamp.

create_link, edit_link, upsert_link, and link all return a Sink::Link:

link = client.create_link(url: "https://example.com", slug: "example")

link.slug                  # => "example"
link.short_link            # => "https://sink.example.com/example"
link.created_at            # => 2026-07-24 12:00:00 UTC
link.expires_at            # => Time or nil
link.expired?              # => false
link.cloaking?             # => false
link.redirect_with_query?  # => true
link.tags                  # => ["articles"]
link.geo                   # => { "US" => "https://example.com/us" }
link.to_h                  # => { slug: "example", url: "https://example.com", ... }
link[:any_future_field]    # raw access to fields without a reader

The API only returns shortLink for the write endpoints, where it builds the value from the protocol and host of the request. short_link is therefore derived from base_url and the slug for link, links, and search_links, which matches the API as long as base_url is the Worker domain that serves the short links.

upsert_link never overwrites an existing slug, so check which happened:

link = client.upsert_link(url: "https://example.com", slug: "example")

link.created?  # => true when the link was stored
link.existing? # => true when the slug already pointed somewhere else

delete_link returns true.

Collections

links, search_links, and check_links return a Sink::Page, which is Enumerable:

page = client.links(limit: 20)

page.map(&:slug)
page.cursor     # => "..." pagination cursor
page.complete?  # => false when more pages are available
page.more?      # => true when a following page can be fetched

Use each_link to walk every page without handling cursors:

client.each_link(tag: "articles") { |link| puts link.short_link }
client.each_link.lazy.select(&:expired?).first(10)

tags returns Sink::Tag records with name and count.

Errors

Non-successful responses raise a subclass of Sink::Error, so a Rails controller can rescue the cases it cares about:

class LinksController < ApplicationController
  rescue_from Sink::NotFound, with: :not_found

  def create
    link = Sink.client.create_link(url: params[:url], slug: params[:slug])
    render json: link.to_h, status: :created
  rescue Sink::Conflict
    render json: { error: "slug already taken" }, status: :conflict
  end
end
Class Raised for
Sink::ValidationError 400, 422 (error.data holds the API details)
Sink::Unauthorized 401, usually a wrong site token
Sink::Forbidden 403, including preview mode
Sink::NotFound 404
Sink::Conflict 409, the slug already exists
Sink::StorageNotReady 423, open Dashboard → Links once after deploying
Sink::RateLimited 429
Sink::ServerError 5xx
Sink::TimeoutError open or read timeout
Sink::ConnectionError DNS, TLS, and socket failures

Every error exposes status, message, body, and data:

begin
  Sink.client.link("missing")
rescue Sink::Error => error
  error.status  # => 404
  error.message # => "Not Found"
  error.body    # => parsed response body
end

Development

bundle install
bundle exec rake test
gem build sink-cool.gemspec

See the Sink API documentation for endpoint and payload details.

Releasing

Configure a trusted publisher for this repository and release.yml on RubyGems.org. Update Sink::VERSION and the matching CHANGELOG.md section, then push a matching version tag:

git tag v0.2.0
git push origin v0.2.0

The release workflow tests Ruby 3.3, 3.4, and 4.0 before publishing to RubyGems.org, GitHub Packages, and GitHub Releases.

License

This gem is available under the MIT License.