Skip to content

About

Blackevin Ruby SDK

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

blackevin

The server-side Ruby SDK for Blackevin realtime.

Sign token requests for your browsers, publish from a job or a webhook, read history and presence, manage queues.

No runtime dependencies. net/http, openssl and json from the standard library — nothing that can break under you in an upgrade. Ruby 3.0 and up, in Rails, Sinatra, Hanami or a plain script.

gem 'blackevin'

The client

You work with an instance. There is no global client and no class-level call that touches the network.

bk = Blackevin::Rest.new

ch = bk.channels.get('room:42')
ch.publish('greeting', {text: 'hi'})

With BLACKEVIN_KEY in the environment, new needs no arguments. Creating one is cheap — it opens no connection and holds no state — so build it where you use it, or keep one in a constant; both are fine, and an instance is thread-safe.

Each part stands alone

Nothing forces a chain through Blackevin::Rest. Every part is a class you can build by itself; without a client: it makes its own from the configuration.

Blackevin::Rest::Auth.new.create_token_request(client_id: current_user.id)
Blackevin::Rest::Channel.new('room:42').publish('greeting', {text: 'hi'})
Blackevin::Rest::Presence.new('room:42').get
Blackevin::Rest::Queues.new.list
Blackevin::Rest::Clients.new.disconnect(user.id)

Pass client: to share one, or to use another key:

bk = Blackevin::Rest.new(key: other_key)

Blackevin::Rest::Queues.new(client: bk).list

bk.auth, bk.channels, bk.clients and bk.queues are these same classes, already holding bk.

Every option can be passed directly:

bk = Blackevin::Rest.new(key: 'ck_live_….kAbC', read_timeout: 3)

Defaults, set once

Blackevin.configure holds the defaults a new Blackevin::Rest starts from. It builds nothing and calls nothing. An argument to new wins over it, and it wins over the environment.

# config/initializers/blackevin.rb
Blackevin.configure do |config|
  config.key = Rails.application.credentials.dig(:blackevin, :key)   # secret.keyId
  config.open_timeout = 5                                          # seconds
  config.read_timeout = 10
end

Against a local or self-hosted node, which serves REST and the socket from one origin, set BLACKEVIN_ENDPOINT=ws://localhost:3000 (or config.endpoint) and the REST base is derived from it.

Token endpoint — the part every app needs

Your API key is your account, so it never goes to a browser. The browser asks your server for a TokenRequest: signed with the key, scoped to what this user may touch, dead after its ttl. Signing is local — no call to Blackevin, so your sign-in never depends on us being reachable.

The browser side is the JavaScript SDK with authUrl: "/blackevin/token".

Rails

# app/controllers/blackevin_tokens_controller.rb
class BlackevinTokensController < ApplicationController
  before_action :authenticate_user!

  def show
    render json: Blackevin::Rest::Auth.new.create_token_request(
      client_id: current_user.id,
      ttl: 1.hour.in_milliseconds,
      capability: {
        "team:#{current_user.team_id}" => %w[subscribe publish],
        "presence:team:#{current_user.team_id}" => %w[presence]
      }
    )
  end
end

# config/routes.rb
resource :blackevin_token, only: :show, path: 'blackevin/token'

The key can live in credentials instead of the environment — the Railtie reads credentials.blackevin.key, and config.blackevin.* in an environment file:

# config/environments/development.rb
config.blackevin.endpoint = 'ws://localhost:3000'

Sinatra

require 'sinatra'
require 'blackevin'

get '/blackevin/token' do
  halt 401 unless current_user

  content_type :json
  Blackevin::Rest::Auth.new.create_token_request(client_id: current_user.id).to_json
end

Anything Rack (Hanami, Roda, config.ru)

Blackevin::TokenEndpoint is a Rack application. The block receives the Rack env and answers who is asking; nil is a 401.

TOKENS = Blackevin::TokenEndpoint.new do |env|
  user = env['warden']&.user

  next unless user

  {client_id: user.id, capability: {"team:#{user.team_id}" => %w[subscribe publish]}}
end

# Hanami:  mount TOKENS, at: '/blackevin/token'
# Rails:   mount TOKENS, at: '/blackevin/token'
# Rack:    map('/blackevin/token') { run TOKENS }

Publish

channel = Blackevin::Rest::Channel.new("orders:#{order.id}")

channel.publish('status', {state: 'shipped'})

It goes through the same fanout a socket publish does: subscribers, account queues and integrations all see it. One HTTP request per publish — from a Rails request, prefer a job:

class BlackevinPublishJob < ApplicationJob
  retry_on Blackevin::ConnectionError, wait: :polynomially_longer

  def perform(channel, event, data)
    Blackevin::Rest::Channel.new(channel).publish(event, data)
  end
end

History and presence

channel.history(limit: 50)        # => [Blackevin::Message], newest first
channel.presence.get              # => [Blackevin::PresenceMember]
channel.presence.history          # => [Blackevin::PresenceEvent]

message = channel.history.first
message.name        # 'status'
message.data        # {'state' => 'shipped'}
message.time        # a Time; message.timestamp is the wire value, in ms

Sign a user out everywhere

Blackevin::Rest::Clients.new.disconnect(user.id)   # => how many connections were closed

Queues

The key needs the amqp-subscribe capability. A queue is addressed by its id.

queues = Blackevin::Rest::Queues.new

queue = queues.upsert(name: 'inbox', max_length: 10_000)
queues.add_rule(queue.id, source_pattern: 'orders:*')

queues.update(queue.id, enabled: false)   # pause
queues.list(all: true)                    # paused ones included
queues.delete(queue.id)

Errors

One rescue covers everything the SDK raises:

begin
  channel.publish('status', payload)
rescue Blackevin::Error => error
  error.status_code   # 403, 429, … or nil when no response arrived
  error.reason        # 'queue_limit', 'connection_limit', or nil
  error.quota?        # a plan ceiling — show an upgrade prompt, retry later
  error.message       # the server's own sentence; do not branch on it
end

Errors, token requests and every returned value support pattern matching, so a rescue can branch by shape:

rescue Blackevin::Error => error
  case error
  in {reason: 'queue_limit'} then redirect_to upgrade_path
  in {status_code: 401 | 403} then raise
  in {status_code: nil} then retry_job wait: 30.seconds
  end
  • Blackevin::ConnectionError — DNS, refused, TLS, timeout. Worth retrying.
  • Blackevin::ConfigurationError — a missing or malformed key, raised before any request.

Acting as one client

A token instead of a key restricts the client to that token's capability:

auth = Blackevin::Rest::Auth.new

details = auth.request_token(
  auth.create_token_request(client_id: 'worker-1', capability: {'jobs:*' => %w[publish]})
)

worker = Blackevin::Rest.new(token: details.token)

Blackevin::Rest::Channel.new('jobs:1', client: worker).publish('done')

Testing your app

Swap the default transport and no Blackevin::Rest leaves the process:

Blackevin.configure do |config|
  config.key = 'secret.test'
  config.transport = ->(request) { Blackevin::Response.new(status: 200, body: '{}') }
end

Development

bundle install
bundle exec rake            # rspec + rubocop (Standard rules, single quotes)
bundle exec rake coverage        # SimpleCov: report in coverage/index.html, fails under the floor
bundle exec rake contract:sync   # refresh spec/contract from ../blackevin/spec

Releasing

Bump lib/blackevin/version.rb, add the entry to CHANGELOG.md, commit, then:

bundle exec rake tag   # tags vX.Y.Z from version.rb and pushes it

The tag runs .github/workflows/release.yml: tests, publish to RubyGems by Trusted Publishing (no API key), and a GitHub release with the .gem attached.

spec/contract is a copy of the language-neutral contract — an OpenAPI file and JSON fixtures — that every Blackevin SDK is tested against. The suite fails when the contract gains an operation this gem does not implement.

License

MIT.

About

Blackevin Ruby SDK

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages