From fe8deb9855cc1d0dc5f884a5d47a590837dbf516 Mon Sep 17 00:00:00 2001 From: FZambia Date: Fri, 24 Apr 2026 20:45:06 +0300 Subject: [PATCH 1/5] changes for v4 release --- .github/workflows/main.yml | 81 +++- .github/workflows/release.yml | 2 +- .gitignore | 2 + .rubocop.yml | 45 +- Appraisals | 21 + Gemfile | 16 +- README.md | 354 ++++++++-------- cent.gemspec | 27 +- docker-compose.yml | 17 + gemfiles/faraday2_jwt2.gemfile | 18 + gemfiles/faraday2_jwt3.gemfile | 18 + gemfiles/faraday3_jwt2.gemfile | 18 + gemfiles/faraday3_jwt3.gemfile | 18 + lib/cent.rb | 2 +- lib/cent/client.rb | 442 ++++++++++---------- lib/cent/error.rb | 45 +- lib/cent/http.rb | 47 --- lib/cent/notary.rb | 125 +++--- lib/cent/version.rb | 2 +- spec/cent/client_spec.rb | 415 ++++++++---------- spec/cent/notary_spec.rb | 199 +++------ spec/cent_spec.rb | 34 +- spec/integration/client_integration_spec.rb | 131 ++++++ spec/spec_helper.rb | 8 +- 24 files changed, 1121 insertions(+), 966 deletions(-) create mode 100644 Appraisals create mode 100644 docker-compose.yml create mode 100644 gemfiles/faraday2_jwt2.gemfile create mode 100644 gemfiles/faraday2_jwt3.gemfile create mode 100644 gemfiles/faraday3_jwt2.gemfile create mode 100644 gemfiles/faraday3_jwt3.gemfile delete mode 100644 lib/cent/http.rb create mode 100644 spec/integration/client_integration_spec.rb diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6899184..0ed60b3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,20 +1,75 @@ -name: Ruby +name: CI -on: [push,pull_request] +on: [push, pull_request] jobs: - build: + lint: + name: RuboCop runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + - run: bundle exec rubocop + + test: + # Matrix cross-product: Ruby × gemfile. Faraday 3 and ruby-head cells are + # marked experimental — they don't block CI until those releases stabilise. + name: Test (Ruby ${{ matrix.ruby }} / ${{ matrix.gemfile }}) + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental == true }} strategy: + fail-fast: false matrix: - ruby-version: ['2.7', '3.3'] + ruby: ['3.4'] + gemfile: + - gemfiles/faraday2_jwt2.gemfile + - gemfiles/faraday2_jwt3.gemfile + experimental: [false] + include: + - ruby: '3.4' + gemfile: gemfiles/faraday3_jwt2.gemfile + experimental: true + - ruby: '3.4' + gemfile: gemfiles/faraday3_jwt3.gemfile + experimental: true + - ruby: 'head' + gemfile: gemfiles/faraday2_jwt3.gemfile + experimental: true + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} + CENTRIFUGO_API_URL: http://localhost:8000/api + CENTRIFUGO_API_KEY: api_key steps: - - uses: actions/checkout@v4 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby-version }} - - name: Run the default task - run: | - bundle install - bundle exec rake + - uses: actions/checkout@v4 + + - name: Start Centrifugo + run: | + docker run -d --name centrifugo \ + -p 8000:8000 -p 10000:10000 \ + -e CENTRIFUGO_HTTP_API_KEY="api_key" \ + -e CENTRIFUGO_CHANNEL_WITHOUT_NAMESPACE_HISTORY_TTL="300s" \ + -e CENTRIFUGO_CHANNEL_WITHOUT_NAMESPACE_HISTORY_SIZE="100" \ + -e CENTRIFUGO_CHANNEL_WITHOUT_NAMESPACE_PRESENCE="true" \ + -e CENTRIFUGO_GRPC_API_ENABLED="true" \ + centrifugo/centrifugo:v6.7.1 centrifugo + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Wait for Centrifugo + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:8000/health >/dev/null; then + echo "Centrifugo ready"; exit 0 + fi + sleep 1 + done + echo "Centrifugo did not become healthy"; docker logs centrifugo; exit 1 + + - name: Run tests + run: bundle exec rspec diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6019e3..1701cf4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.0 + ruby-version: '3.4' - name: Bundle install run: | diff --git a/.gitignore b/.gitignore index a472d5e..534436f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ /.bundle/ /.yardoc /Gemfile.lock +/gemfiles/*.lock +/gemfiles/vendor/ /_yardoc/ /coverage/ /doc/ diff --git a/.rubocop.yml b/.rubocop.yml index 7a29490..ccc40e0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,13 +1,52 @@ -require: +plugins: - rubocop-rspec - rubocop-rake + AllCops: - TargetRubyVersion: 2.5 + TargetRubyVersion: 3.0 NewCops: enable -RSpec/ExampleLength: + Exclude: + - 'gemfiles/**/*' + - 'vendor/**/*' + +Metrics/ClassLength: + Max: 160 + +Metrics/MethodLength: + Max: 30 + +Metrics/ParameterLists: Max: 15 + CountKeywordArgs: false + +Metrics/AbcSize: + Max: 25 + Metrics/BlockLength: Exclude: - 'spec/**/*' + - '*.gemspec' + - 'Appraisals' + +RSpec/ExampleLength: + Max: 25 + +RSpec/MultipleExpectations: + Max: 5 + +RSpec/NestedGroups: + Max: 4 + +# Stubs with `with(body: ...)` act as implicit expectations: a mismatched +# request body leaves no matching stub and fails the request. +RSpec/NoExpectationExample: + Exclude: + - 'spec/cent/client_spec.rb' + +# Integration specs live under spec/integration/ by design. +RSpec/SpecFilePathFormat: + Exclude: + - 'spec/integration/**/*' + Gemspec/RequireMFA: Enabled: false diff --git a/Appraisals b/Appraisals new file mode 100644 index 0000000..63fc347 --- /dev/null +++ b/Appraisals @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +appraise 'faraday2-jwt2' do + gem 'faraday', '~> 2.0' + gem 'jwt', '~> 2.2' +end + +appraise 'faraday2-jwt3' do + gem 'faraday', '~> 2.0' + gem 'jwt', '~> 3.0' +end + +appraise 'faraday3-jwt2' do + gem 'faraday', '~> 3.0' + gem 'jwt', '~> 2.2' +end + +appraise 'faraday3-jwt3' do + gem 'faraday', '~> 3.0' + gem 'jwt', '~> 3.0' +end diff --git a/Gemfile b/Gemfile index b6065ea..e8b9827 100644 --- a/Gemfile +++ b/Gemfile @@ -5,13 +5,11 @@ source 'https://rubygems.org' # Specify your gem's dependencies in cent.gemspec gemspec -gem 'rake', '~> 13.0' - -gem 'rspec', '~> 3.0' -gem 'webmock', '~> 3.7.5' - -gem 'rubocop', '~> 1.7' -gem 'rubocop-rake' -gem 'rubocop-rspec' - +gem 'appraisal', '~> 2.5' gem 'pry' +gem 'rake', '~> 13.0' +gem 'rspec', '~> 3.12' +gem 'rubocop', '~> 1.60' +gem 'rubocop-rake', '~> 0.6' +gem 'rubocop-rspec', '~> 3.0' +gem 'webmock', '~> 3.19' diff --git a/README.md b/README.md index 28e808b..a01f43f 100644 --- a/README.md +++ b/README.md @@ -1,281 +1,263 @@ -[![Code Climate](https://codeclimate.com/github/centrifugal/centrifuge-ruby/badges/gpa.svg)](https://codeclimate.com/github/centrifugal/centrifuge-ruby) +# cent + ![Build Status](https://github.com/centrifugal/rubycent/actions/workflows/main.yml/badge.svg) -[Centrifugo HTTP API](https://centrifugal.dev/docs/server/server_api) client in Ruby. +Ruby client for the [Centrifugo](https://centrifugal.dev) server HTTP API. -## Installation +- `Cent::Client` — call server API methods (publish, broadcast, presence, history, …). +- `Cent::Notary` — issue connection and subscription JWTs. -Add this line to your application's Gemfile: +Works with Centrifugo **v4 and newer** (tested against v6.7.1). Ruby 3.0+. + +## Installation ```ruby -gem 'cent' +gem 'cent', '~> 4.0' ``` -And then execute: - - $ bundle - -Or install it yourself as: +```sh +$ bundle install +``` - $ gem install cent +## API client -## Usage +```ruby +client = Cent::Client.new(api_key: 'your-api-key') +# Or pointing at a remote Centrifugo: +client = Cent::Client.new( + api_key: 'your-api-key', + endpoint: 'https://centrifugo.example.com/api', + timeout: 5 +) +``` -Functionality is split between two classes: - - `Cent::Client` to call API methods - - `Cent::Notary` to generate tokens +Every method returns the parsed response body from Centrifugo: -### Token Generation +- On success the body has a `"result"` key: `{ "result" => { ... } }`. +- On an API-level failure (e.g. unknown channel, namespace not found) `Cent::ResponseError` is raised with Centrifugo's numeric `code` and `message`. +- On a transport problem (network failure, timeout, non-2xx HTTP, malformed JSON) a `Cent::Error` subclass is raised. -```ruby -notary = Cent::Notary.new(secret: 'secret') -``` +`batch` and `broadcast` are different — see their sections below. -By default it uses HS256 to generate tokens, but you can set it to one of the HMAC, RSA or ECDSA family. +### Customizing the connection -#### RSA +The initializer yields the underlying [`Faraday::Connection`](https://lostisland.github.io/faraday/) so you can adjust headers, timeouts, adapter, etc. ```ruby -secret = OpenSSL::PKey::RSA.new(File.read('./rsa_secret.pem')) -notary = Cent::Notary.new(secret: secret, algorithm: 'RS256') +Cent::Client.new(api_key: 'k') do |conn| + conn.headers['User-Agent'] = 'my-app/1.0' + conn.options.open_timeout = 3 + conn.options.timeout = 7 + conn.adapter :typhoeus +end ``` -#### ECDSA +### Publishing ```ruby -secret = OpenSSL::PKey::EC.new(File.read('./ecdsa_secret.pem')) -notary = Cent::Notary.new(secret: secret, algorithm: 'ES256') +client.publish(channel: 'chat', data: { text: 'hello' }) +# => {"result" => {"offset" => 1, "epoch" => "xyz"}} + +client.publish( + channel: 'chat', + data: { text: 'hello' }, + skip_history: false, + tags: { 'author' => '42' }, + idempotency_key: 'my-idempotency-key', + delta: true +) ``` -#### Connection token +See [publish](https://centrifugal.dev/docs/server/server_api#publish). -When connecting to Centrifugo client [must provide connection JWT token](https://centrifugal.github.io/centrifugo/server/authentication/) with several predefined credential claims. +### Broadcast ```ruby -notary.issue_connection_token(sub: '42') - -#=> "eyJhbGciOiJIUzI1NiJ9..." +response = client.broadcast(channels: %w[chat:1 chat:2], data: { text: 'hi' }) +# response => { "result" => { "responses" => [ {"result" => {...}}, {"result" => {...}} ] } } ``` -`info` and `exp` are supported as well: +The outer call only raises `Cent::ResponseError` if the whole broadcast is rejected (e.g. malformed request). Per-channel failures are delivered as individual entries in `response["result"]["responses"]`, each of which may contain an `"error"` key — **these are not raised**. Walk the array to check them: ```ruby -notary.issue_connection_token(sub: '42', info: { scope: 'admin' }, exp: 1629050099) - -#=> "eyJhbGciOiJIUzI1NiJ9..." +response['result']['responses'].each_with_index do |r, i| + warn "channel #{i} failed: #{r['error']['message']}" if r['error'] +end ``` -### Private channel token - -All channels starting with $ considered private and require a **channel token** to subscribe. -Private channel subscription token is also JWT([see the claims](https://centrifugal.github.io/centrifugo/server/private_channels/)) +### Subscribe / Unsubscribe ```ruby -notary.issue_channel_token(sub: '42', channel: 'channel', exp: 1629050099, info: { scope: 'admin' }) - -#=> "eyJhbGciOiJIUzI1NiJ9..." +client.subscribe(user: '42', channel: 'chat') +client.unsubscribe(user: '42', channel: 'chat') ``` - -### API Client -A client requires your Centrifugo API key to execute all requests. +### Disconnect / Refresh ```ruby -client = Cent::Client.new(api_key: 'key') +client.disconnect(user: '42') +client.disconnect(user: '42', whitelist: %w[keep-this-client-id]) + +client.refresh(user: '42', expired: true) ``` -you can customize your connection as you wish, just remember it's a [Faraday::Connection](https://lostisland.github.io/faraday/usage/#customizing-faradayconnection) instance: +### Presence / Presence stats ```ruby -client = Cent::Client.new(api_key: 'key', endpoint: 'https://centrifu.go/api') do |connection| - connection.headers['User-Agent'] = 'Centrifugo Ruby Client' - connection.options.open_timeout = 3 - connection.options.timeout = 7 - connection.adapter :typhoeus -end +client.presence(channel: 'chat') +client.presence_stats(channel: 'chat') ``` -#### Publish - -Send data to the channel. - -[publish](https://centrifugal.dev/docs/server/server_api#publish) +### History ```ruby -client.publish(channel: 'chat', data: 'hello') # => {} +client.history(channel: 'chat', limit: 10) +client.history(channel: 'chat', limit: 10, reverse: true) +client.history(channel: 'chat', limit: 10, since: { 'offset' => 5, 'epoch' => 'xyz' }) +client.history_remove(channel: 'chat') ``` -#### Broadcast - -Sends data to multiple channels. - -[broadcast](https://centrifugal.dev/docs/server/server_api#broadcast) +### Channels ```ruby -client.broadcast(channels: ["clients", "staff"], data: 'hello') # => {} +client.channels +client.channels(pattern: 'chat:*') ``` -#### Unsubscribe - -Unsubscribe user from channel. Receives to arguments: channel and user (user ID you want to unsubscribe) - -[unsubscribe](https://centrifugal.dev/docs/server/server_api#unsubscribe) +### Info ```ruby -client.unsubscribe(channel: 'chat', user: '1') # => {} +client.info ``` -#### Disconnect +### Batch -Allows to disconnect user by it's ID. Receives user ID as an argument. - -[disconnect](https://centrifugal.dev/docs/server/server_api#disconnect) +Send many commands in one HTTP request — Centrifugo processes them sequentially (or in parallel with `parallel: true`) and returns one reply per command in the same order. ```ruby -# Disconnect user with `id = 1` -# -client.disconnect(user: '1') # => {} +response = client.batch(commands: [ + { 'publish' => { 'channel' => 'a', 'data' => { 'x' => 1 } } }, + { 'publish' => { 'channel' => 'b', 'data' => { 'x' => 2 } } }, + { 'presence_stats' => { 'channel' => 'a' } } +]) +# => { "replies" => [ {"publish" => {...}}, {"publish" => {...}}, {"presence_stats" => {...}} ] } ``` -#### Presence +Two things about batch are different from every other method: -Get channel presence information(all clients currently subscribed on this channel). +1. **No `result` wrapper.** The response is `{ "replies" => [...] }` at the top level. This matches Centrifugo's wire format. +2. **Per-command errors are not raised.** Each entry in `replies` may instead be `{ "error" => { "code" => ..., "message" => ... } }`. Raising on the first would make partial-success responses impossible to inspect — so the caller is expected to walk the array: -[presence](https://centrifugal.dev/docs/server/server_api#presence) + ```ruby + response['replies'].each_with_index do |reply, i| + if reply['error'] + warn "command #{i} failed: #{reply['error']['code']} #{reply['error']['message']}" + end + end + ``` -```ruby -client.presence(channel: 'chat') - -# { -# 'result' => { -# 'presence' => { -# 'c54313b2-0442-499a-a70c-051f8588020f' => { -# 'client' => 'c54313b2-0442-499a-a70c-051f8588020f', -# 'user' => '42' -# }, -# 'adad13b1-0442-499a-a70c-051f858802da' => { -# 'client' => 'adad13b1-0442-499a-a70c-051f858802da', -# 'user' => '42' -# } -# } -# } -# } -``` - -#### Presence stats - -Get short channel presence information. - -[presence_stats](https://centrifugal.dev/docs/server/server_api#presence_stats) + `Cent::ResponseError` is still raised if Centrifugo rejects the batch request as a whole (e.g. malformed top-level body). -```ruby -client.presence_stats(channel: 'chat') +### Error handling -# { -# "result" => { -# "num_clients" => 0, -# "num_users" => 0 -# } -# } +```ruby +begin + response = client.publish(channel: 'chat', data: 'hi') +rescue Cent::ResponseError => e + # Centrifugo rejected the request (e.g. unknown channel, namespace not found). + puts "Centrifugo error #{e.code}: #{e.message}" +rescue Cent::TimeoutError + # request timed out +rescue Cent::NetworkError + # connection refused / DNS failure / etc. +rescue Cent::UnauthorizedError => e + # HTTP 401 — API key is wrong +rescue Cent::TransportError => e + # other 4xx/5xx — e.status has the HTTP code +rescue Cent::DecodeError + # response body wasn't valid JSON +end ``` -#### History - -Get channel history information (list of last messages published into channel). +All of the above inherit from `Cent::Error`, so you can rescue that single class if you don't need to discriminate. -[history](https://centrifugal.dev/docs/server/server_api#history) +## Token generation ```ruby -client.history(channel: 'chat') - -# { -# 'result' => { -# 'publications' => [ -# { -# 'data' => { -# 'text' => 'hello' -# } -# }, -# { -# 'data' => { -# 'text' => 'hi!' -# } -# } -# ] -# } -# } +notary = Cent::Notary.new(secret: 'hmac-secret') # HS256 +notary = Cent::Notary.new(secret: rsa_private_key, algorithm: 'RS256') # RSA +notary = Cent::Notary.new(secret: ec_private_key, algorithm: 'ES256') # ECDSA ``` -#### Channels +### Connection token -Get list of active(with one or more subscribers) channels. - -[channels](https://centrifugal.dev/docs/server/server_api#channels) +Used by clients to establish a real-time connection. See [authentication](https://centrifugal.dev/docs/server/authentication). ```ruby -client.channels - -# { -# 'result' => { -# 'channels' => [ -# 'chat' -# ] -# } -# } +notary.issue_connection_token(sub: '42') +notary.issue_connection_token(sub: '42', exp: Time.now.to_i + 600, info: { name: 'Alex' }) + +# With any of the standard/Centrifugo claims: +notary.issue_connection_token( + sub: '42', exp: 1735689600, iat: 1735686000, jti: SecureRandom.uuid, + aud: 'centrifugo', iss: 'my-app', + info: { role: 'admin' }, meta: { tenant: 'acme' }, + channels: %w[user:42 news], + subs: { 'room:1' => { 'data' => { 'welcome' => true } } } +) ``` -#### Info +### Subscription token -Get running Centrifugo nodes information. - -[info](https://centrifugal.dev/docs/server/server_api#info) +Used by clients to subscribe to a channel that requires token authorization. See [channel token auth](https://centrifugal.dev/docs/server/channel_token_auth). ```ruby -client.info - -# { -# 'result' => { -# 'nodes' => [ -# { -# 'name' => 'Alexanders-MacBook-Pro.local_8000', -# 'num_channels' => 0, -# 'num_clients' => 0, -# 'num_users' => 0, -# 'uid' => 'f844a2ed-5edf-4815-b83c-271974003db9', -# 'uptime' => 0, -# 'version' => '' -# } -# ] -# } -# } +notary.issue_channel_token(sub: '42', channel: 'private-chat', exp: 1735689600) +notary.issue_channel_token( + sub: '42', channel: 'private-chat', + info: { role: 'writer' }, + override: { 'presence' => { 'value' => true } } +) ``` -### Errors +## Migrating from v3 -Network errors are not wrapped and will raise `Faraday::ClientError`. +v4 is a breaking release. Expect to touch a few call sites. -In cases when Centrifugo returns 200 with `error` key in the body we wrap it and return custom error: +- **Centrifugo v4+ is required.** v3 of this gem spoke the legacy `POST /api` JSON-RPC-style protocol; v4 uses the current per-method endpoints (`POST /api/publish`, `POST /api/broadcast`, …) and sends the API key as `X-API-Key` instead of `Authorization: apikey `. +- **Error handling is unchanged for the common case.** `Cent::ResponseError` still exists and is still raised when Centrifugo returns a top-level API error. Existing `rescue Cent::ResponseError => e` blocks using `e.code` / `e.message` keep working. The new additions are typed transport errors — `Cent::TimeoutError`, `Cent::NetworkError`, `Cent::TransportError`, `Cent::UnauthorizedError`, `Cent::DecodeError` — all subclassed under `Cent::Error`. +- **Keyword arg rename**: `Cent::Notary#issue_channel_token(client:)` → `issue_channel_token(sub:)` to match Centrifugo's standard `sub` JWT claim. +- **`unsubscribe` takes `user:`** now (was previously `user:` too, but is now validated and paired with `channel:`). +- **Ruby 3.0+** is required (was 2.5+). Faraday 2 and Faraday 3 are both supported; JWT 2 and JWT 3 are both supported. +- **New methods** added for common Centrifugo operations: `subscribe`, `refresh`, `history_remove`, `batch`. +- **Richer kwargs** on existing methods (e.g. `publish` now accepts `tags`, `skip_history`, `idempotency_key`, `delta`, `version`, `version_epoch`, `b64data`; `history` accepts `limit`, `since`, `reverse`; `channels` accepts `pattern`). -```ruby -# Raised when response from Centrifugo contains any error as result of API command execution. -# -begin - client.publish(channel: 'channel', data: { foo: :bar }) -rescue Cent::ResponseError => ex - ex.message # => "Invalid format" -end -``` +See `release_v4.0.0.md` for the full list of changes. ## Development -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +```sh +$ bin/setup # install dependencies +$ bundle exec rspec # run unit tests +$ bundle exec rubocop # lint +``` + +### Running integration tests -To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). +Integration tests under `spec/integration/` exercise a real Centrifugo server. They're skipped unless `CENTRIFUGO_API_URL` is set. -## Contributing +```sh +$ docker compose up -d +$ CENTRIFUGO_API_URL=http://localhost:8000/api CENTRIFUGO_API_KEY=api_key bundle exec rspec spec/integration +``` + +### Testing across Faraday / JWT versions -Bug reports and pull requests are welcome on GitHub at https://github.com/centrifugal/rubycent. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. +```sh +$ bundle exec appraisal install # generate gemfiles/*.gemfile.lock +$ bundle exec appraisal rspec # run the full matrix locally +``` ## License -The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). +MIT — see [LICENSE.txt](LICENSE.txt). diff --git a/cent.gemspec b/cent.gemspec index 4f749ff..02db718 100644 --- a/cent.gemspec +++ b/cent.gemspec @@ -5,31 +5,32 @@ require_relative 'lib/cent/version' Gem::Specification.new do |spec| spec.name = 'cent' spec.version = Cent::VERSION - spec.authors = ['Sergey Prikhodko'] + spec.authors = ['Sergey Prikhodko', 'Centrifugal Labs'] spec.email = ['prikha@gmail.com'] - spec.summary = 'Centrifugo API V2 Ruby Client' + spec.summary = 'Centrifugo server API client for Ruby' spec.description = <<~DESC - Provides helper classes Cent::Client and Cent::Notary. - - `Cent::Client` is made to communicate to the server API - `Client::Notary` is a simple JWT wrapper to generate authorization tokens for the frontend + Ruby client for Centrifugo server HTTP API. Provides Cent::Client to call + Centrifugo server methods (publish, broadcast, subscribe, presence, history, ...) + and Cent::Notary to issue JWT connection and subscription tokens. DESC spec.homepage = 'https://github.com/centrifugal/rubycent' spec.license = 'MIT' - spec.required_ruby_version = Gem::Requirement.new('>= 2.5.0') + spec.required_ruby_version = Gem::Requirement.new('>= 3.0') - spec.metadata['homepage_uri'] = spec.homepage spec.metadata['source_code_uri'] = 'https://github.com/centrifugal/rubycent' + spec.metadata['changelog_uri'] = 'https://github.com/centrifugal/rubycent/releases' + spec.metadata['bug_tracker_uri'] = 'https://github.com/centrifugal/rubycent/issues' - # Specify which files should be added to the gem when it is released. - # The `git ls-files -z` loads the files in the RubyGem that have been added into git. spec.files = Dir.chdir(File.expand_path(__dir__)) do - `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:spec|Gemfile)/}) } + `git ls-files -z`.split("\x0").reject do |f| + f.match(%r{\A(?:spec|benchmarks|gemfiles|\.github)/}) || + f.match(/\A(?:docker-compose\.yml|Appraisals|\.rubocop\.yml|\.rspec|\.gitignore)\z/) + end end spec.require_paths = ['lib'] - spec.add_dependency 'faraday', '> 2.0.0' - spec.add_dependency 'jwt', '> 2.2.0' + spec.add_dependency 'faraday', '>= 2.0', '< 4' + spec.add_dependency 'jwt', '>= 2.2', '< 4' end diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c444672 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + centrifugo: + image: centrifugo/centrifugo:v6.7.1 + command: centrifugo + ports: + - "8000:8000" + - "10000:10000" + ulimits: + nofile: + soft: 65536 + hard: 65536 + environment: + CENTRIFUGO_HTTP_API_KEY: api_key + CENTRIFUGO_CHANNEL_WITHOUT_NAMESPACE_HISTORY_TTL: 300s + CENTRIFUGO_CHANNEL_WITHOUT_NAMESPACE_HISTORY_SIZE: 100 + CENTRIFUGO_CHANNEL_WITHOUT_NAMESPACE_PRESENCE: "true" + CENTRIFUGO_GRPC_API_ENABLED: "true" diff --git a/gemfiles/faraday2_jwt2.gemfile b/gemfiles/faraday2_jwt2.gemfile new file mode 100644 index 0000000..ac01982 --- /dev/null +++ b/gemfiles/faraday2_jwt2.gemfile @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source 'https://rubygems.org' + +gem 'appraisal', '~> 2.5' +gem 'faraday', '~> 2.0' +gem 'jwt', '~> 2.2' +gem 'pry' +gem 'rake', '~> 13.0' +gem 'rspec', '~> 3.12' +gem 'rubocop', '~> 1.60' +gem 'rubocop-rake', '~> 0.6' +gem 'rubocop-rspec', '~> 3.0' +gem 'webmock', '~> 3.19' + +gemspec path: '../' diff --git a/gemfiles/faraday2_jwt3.gemfile b/gemfiles/faraday2_jwt3.gemfile new file mode 100644 index 0000000..0b42e29 --- /dev/null +++ b/gemfiles/faraday2_jwt3.gemfile @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source 'https://rubygems.org' + +gem 'appraisal', '~> 2.5' +gem 'faraday', '~> 2.0' +gem 'jwt', '~> 3.0' +gem 'pry' +gem 'rake', '~> 13.0' +gem 'rspec', '~> 3.12' +gem 'rubocop', '~> 1.60' +gem 'rubocop-rake', '~> 0.6' +gem 'rubocop-rspec', '~> 3.0' +gem 'webmock', '~> 3.19' + +gemspec path: '../' diff --git a/gemfiles/faraday3_jwt2.gemfile b/gemfiles/faraday3_jwt2.gemfile new file mode 100644 index 0000000..47766b6 --- /dev/null +++ b/gemfiles/faraday3_jwt2.gemfile @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source 'https://rubygems.org' + +gem 'appraisal', '~> 2.5' +gem 'faraday', '~> 3.0' +gem 'jwt', '~> 2.2' +gem 'pry' +gem 'rake', '~> 13.0' +gem 'rspec', '~> 3.12' +gem 'rubocop', '~> 1.60' +gem 'rubocop-rake', '~> 0.6' +gem 'rubocop-rspec', '~> 3.0' +gem 'webmock', '~> 3.19' + +gemspec path: '../' diff --git a/gemfiles/faraday3_jwt3.gemfile b/gemfiles/faraday3_jwt3.gemfile new file mode 100644 index 0000000..c20a8a8 --- /dev/null +++ b/gemfiles/faraday3_jwt3.gemfile @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source 'https://rubygems.org' + +gem 'appraisal', '~> 2.5' +gem 'faraday', '~> 3.0' +gem 'jwt', '~> 3.0' +gem 'pry' +gem 'rake', '~> 13.0' +gem 'rspec', '~> 3.12' +gem 'rubocop', '~> 1.60' +gem 'rubocop-rake', '~> 0.6' +gem 'rubocop-rspec', '~> 3.0' +gem 'webmock', '~> 3.19' + +gemspec path: '../' diff --git a/lib/cent.rb b/lib/cent.rb index 764f69a..272a628 100644 --- a/lib/cent.rb +++ b/lib/cent.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true require 'cent/version' +require 'cent/error' require 'cent/notary' require 'cent/client' # Centrifugo Ruby Client module Cent - # Here will be code... end diff --git a/lib/cent/client.rb b/lib/cent/client.rb index 8ff9b30..17d3f53 100644 --- a/lib/cent/client.rb +++ b/lib/cent/client.rb @@ -1,272 +1,260 @@ # frozen_string_literal: true require 'faraday' -require 'cent/http' +require 'cent/error' module Cent # Cent::Client # - # Main object that handles configuration and requests to centrifugo API + # Ruby client for Centrifugo server HTTP API (Centrifugo v4+). # + # Every API method returns the raw parsed response body from Centrifugo — + # typically `{ "result" => { ... } }` on success. If Centrifugo rejects the + # request with a top-level `error`, {Cent::ResponseError} is raised with + # Centrifugo's numeric `code` and `message`. Transport-level problems + # (network failure, timeout, non-2xx HTTP status, unparseable body) raise + # other {Cent::Error} subclasses. + # + # {#batch} and {#broadcast} are special: their responses contain an array + # of independent sub-replies, each of which may carry its own `error` + # field. Those sub-reply errors are NOT raised — callers inspect them + # manually. See {#batch} for details. + # + # @example Basic usage + # client = Cent::Client.new(api_key: 'secret') + # client.publish(channel: 'chat', data: { text: 'hi' }) + # # => {"result" => {}} + # + # @example Custom Faraday configuration + # Cent::Client.new(api_key: 'k', endpoint: 'https://c.example.com/api') do |conn| + # conn.options.open_timeout = 3 + # conn.options.timeout = 7 + # conn.adapter :typhoeus + # end class Client - # @param endpoint [String] - # (default: 'http://localhost:8000/api') Centrifugo HTTP API URL - # - # @param api_key [String] - # Centrifugo API key(used to perform requests) - # - # @yield [Faraday::Connection] yields connection object so that it can be configured - # - # @example Construct new client instance - # Cent::Client.new( - # endpoint: 'http://localhost:8000/api', - # api_key: 'api key' - # ) - # - def initialize(api_key:, endpoint: 'http://localhost:8000/api') + DEFAULT_ENDPOINT = 'http://localhost:8000/api' + DEFAULT_TIMEOUT = 10 + + attr_reader :connection + + # @param api_key [String] Centrifugo HTTP API key (sent as `X-API-Key`). + # @param endpoint [String] Centrifugo HTTP API base URL. + # @param timeout [Numeric] Request timeout in seconds. + # @yield [Faraday::Connection] optional block to further configure the connection. + def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, timeout: DEFAULT_TIMEOUT, &block) headers = { 'Content-Type' => 'application/json', - 'Authorization' => "apikey #{api_key}" + 'X-API-Key' => api_key } - @connection = Faraday.new(endpoint, headers: headers) do |conn| - conn.request :json # encode req bodies as JSON + base = endpoint.end_with?('/') ? endpoint : "#{endpoint}/" - conn.response :json # decode response bodies as JSON + @connection = Faraday.new(base, headers: headers) do |conn| + conn.options.timeout = timeout + conn.options.open_timeout = timeout + conn.request :json + conn.response :json conn.response :raise_error - - yield conn if block_given? + block&.call(conn) end end - # Publish data into channel - # - # @param channel [String] - # Name of the channel to publish - # - # @param data [Hash] - # Data for publication in the channel - # - # @example Publish `content: 'hello'` into `chat` channel - # client.publish(channel: 'chat', data: 'hello') #=> {} - # - # @see (https://centrifugal.github.io/centrifugo/server/http_api/#publish) - # - # @raise [Cent::Error, Cent::ResponseError] - # - # @return [Hash] Return empty hash in case of successful publish - # - def publish(channel:, data:) - execute('publish', channel: channel, data: data) + # Publish data into a channel. + # @see https://centrifugal.dev/docs/server/server_api#publish + def publish(channel:, data:, skip_history: nil, tags: nil, b64data: nil, + idempotency_key: nil, delta: nil, version: nil, version_epoch: nil) + send_command('publish', { + 'channel' => channel, + 'data' => data, + 'skip_history' => skip_history, + 'tags' => tags, + 'b64data' => b64data, + 'idempotency_key' => idempotency_key, + 'delta' => delta, + 'version' => version, + 'version_epoch' => version_epoch + }) end - # Publish data into multiple channels - # (Similar to `#publish` but allows to send the same data into many channels) - # - # @param channels [Array] Collection of channels names to publish - # @param data [Hash] Data for publication in the channels - # - # @example Broadcast `content: 'hello'` into `channel_1`, 'channel_2' channels - # client.broadcast(channels: ['channel_1', 'channel_2'], data: 'hello') #=> {} - # - # @see (https://centrifugal.github.io/centrifugo/server/http_api/#broadcast) - # - # @raise [Cent::Error, Cent::ResponseError] - # - # @return [Hash] Return empty hash in case of successful broadcast - # - def broadcast(channels:, data:) - execute('broadcast', channels: channels, data: data) + # Publish the same data into many channels. + # @see https://centrifugal.dev/docs/server/server_api#broadcast + def broadcast(channels:, data:, skip_history: nil, tags: nil, b64data: nil, + idempotency_key: nil, delta: nil, version: nil, version_epoch: nil) + send_command('broadcast', { + 'channels' => channels, + 'data' => data, + 'skip_history' => skip_history, + 'tags' => tags, + 'b64data' => b64data, + 'idempotency_key' => idempotency_key, + 'delta' => delta, + 'version' => version, + 'version_epoch' => version_epoch + }) end - # Unsubscribe user from channel - # - # @param channel [String] - # Channel name to unsubscribe from - # - # @param user [String, Integer] - # User ID you want to unsubscribe - # - # @example Unsubscribe user with `id = 1` from `chat` channel - # client.unsubscribe(channel: 'chat', user: '1') #=> {} - # - # @see (https://centrifugal.github.io/centrifugo/server/http_api/#unsubscribe) - # - # @raise [Cent::Error, Cent::ResponseError] - # - # @return [Hash] Return empty hash in case of successful unsubscribe - # - def unsubscribe(channel:, user:) - execute('unsubscribe', channel: channel, user: user) + # Subscribe a user's active session to a channel (server-side subscription). + # @see https://centrifugal.dev/docs/server/server_api#subscribe + def subscribe(user:, channel:, info: nil, b64info: nil, client: nil, session: nil, + data: nil, b64data: nil, recover_since: nil, override: nil) + send_command('subscribe', { + 'user' => user, + 'channel' => channel, + 'info' => info, + 'b64info' => b64info, + 'client' => client, + 'session' => session, + 'data' => data, + 'b64data' => b64data, + 'recover_since' => recover_since, + 'override' => override + }) end - # Disconnect user by it's ID - # - # @param user [String, Integer] - # User ID you want to disconnect - # - # @example Disconnect user with `id = 1` - # client.disconnect(user: '1') #=> {} - # - # @see (https://centrifugal.github.io/centrifugo/server/http_api/#disconnect) - # - # @raise [Cent::Error, Cent::ResponseError] - # - # @return [Hash] Return empty hash in case of successful disconnect - # - def disconnect(user:) - execute('disconnect', user: user) + # Unsubscribe a user from a channel. + # @see https://centrifugal.dev/docs/server/server_api#unsubscribe + def unsubscribe(user:, channel:, client: nil, session: nil) + send_command('unsubscribe', { + 'user' => user, + 'channel' => channel, + 'client' => client, + 'session' => session + }) end - # Get channel presence information - # (all clients currently subscribed on this channel) - # - # @param channel [String] Name of the channel - # - # @example Get presence information for channel `chat` - # client.presence(channel: 'chat') #=> { - # "result" => { - # "presence" => { - # "c54313b2-0442-499a-a70c-051f8588020f" => { - # "client" => "c54313b2-0442-499a-a70c-051f8588020f", - # "user" => "42" - # }, - # "adad13b1-0442-499a-a70c-051f858802da" => { - # "client" => "adad13b1-0442-499a-a70c-051f858802da", - # "user" => "42" - # } - # } - # } - # } - # - # @see (https://centrifugal.github.io/centrifugo/server/http_api/#presence) - # - # @raise [Cent::Error, Cent::ResponseError] - # - # @return [Hash] - # Return hash with information about all clients currently subscribed on this channel - # + # Disconnect a user by ID. + # @see https://centrifugal.dev/docs/server/server_api#disconnect + def disconnect(user:, client: nil, session: nil, whitelist: nil, disconnect: nil) + send_command('disconnect', { + 'user' => user, + 'client' => client, + 'session' => session, + 'whitelist' => whitelist, + 'disconnect' => disconnect + }) + end + + # Refresh a user connection (for unidirectional transports). + # @see https://centrifugal.dev/docs/server/server_api#refresh + def refresh(user:, client: nil, session: nil, expired: nil, expire_at: nil) + send_command('refresh', { + 'user' => user, + 'client' => client, + 'session' => session, + 'expired' => expired, + 'expire_at' => expire_at + }) + end + + # Get channel presence (all currently subscribed clients). + # @see https://centrifugal.dev/docs/server/server_api#presence def presence(channel:) - execute('presence', channel: channel) + send_command('presence', { 'channel' => channel }) end - # Get short channel presence information - # - # @param channel [String] Name of the channel - # - # @example Get short presence information for channel `chat` - # client.presence_stats(channel: 'chat') #=> { - # "result" => { - # "num_clients" => 0, - # "num_users" => 0 - # } - # } - # - # @see (https://centrifugal.github.io/centrifugo/server/http_api/#presence_stats) - # - # @raise [Cent::Error, Cent::ResponseError] - # - # @return [Hash] - # Return hash with short presence information about channel - # + # Get short presence stats for a channel. + # @see https://centrifugal.dev/docs/server/server_api#presence_stats def presence_stats(channel:) - execute('presence_stats', channel: channel) + send_command('presence_stats', { 'channel' => channel }) end - # Get channel history information - # (list of last messages published into channel) - # - # @param channel [String] Name of the channel - # - # @example Get history for channel `chat` - # client.history(channel: 'chat') #=> { - # "result" => { - # "publications" => [ - # { - # "data" => { - # "text" => "hello" - # }, - # "uid" => "BWcn14OTBrqUhTXyjNg0fg" - # }, - # { - # "data" => { - # "text" => "hi!" - # }, - # "uid" => "Ascn14OTBrq14OXyjNg0hg" - # } - # ] - # } - # } - # - # @see (https://centrifugal.github.io/centrifugo/server/http_api/#history) - # - # @raise [Cent::Error, Cent::ResponseError] - # - # @return [Hash] - # Return hash with a list of last messages published into channel - # - def history(channel:) - execute('history', channel: channel) + # Get channel history (recent publications). + # @see https://centrifugal.dev/docs/server/server_api#history + def history(channel:, limit: nil, since: nil, reverse: nil) + send_command('history', { + 'channel' => channel, + 'limit' => limit, + 'since' => since, + 'reverse' => reverse + }) end - # Get list of active(with one or more subscribers) channels. - # - # @example Get active channels list - # client.channels #=> { - # "result" => { - # "channels" => [ - # "chat" - # ] - # } - # } - # - # @see (https://centrifugal.github.io/centrifugo/server/http_api/#channels) - # - # @raise [Cent::Error, Cent::ResponseError] - # - # @return [Hash] - # Return hash with a list of active channels - # - def channels - execute('channels', {}) + # Remove all publications from a channel's history. + # @see https://centrifugal.dev/docs/server/server_api#history_remove + def history_remove(channel:) + send_command('history_remove', { 'channel' => channel }) end - # Get information about running Centrifugo nodes - # - # @example Get running centrifugo nodes list - # client.info #=> { - # "result" => { - # "nodes" => [ - # { - # "name" => "Alexanders-MacBook-Pro.local_8000", - # "num_channels" => 0, - # "num_clients" => 0, - # "num_users" => 0, - # "uid" => "f844a2ed-5edf-4815-b83c-271974003db9", - # "uptime" => 0, - # "version" => "" - # } - # ] - # } - # } - # - # @see (https://centrifugal.github.io/centrifugo/server/http_api/#info) - # - # @raise [Cent::Error, Cent::ResponseError] - # - # @return [Hash] - # Return hash with a list of last messages published into channel - # + # List active channels (channels with at least one subscriber). + # @see https://centrifugal.dev/docs/server/server_api#channels + def channels(pattern: nil) + send_command('channels', { 'pattern' => pattern }) + end + + # Get information about running Centrifugo nodes. + # @see https://centrifugal.dev/docs/server/server_api#info def info - execute('info', {}) + send_command('info', {}) + end + + # Send many commands in a single request. + # + # The response is shaped `{ "replies" => [, ...] }` — note there + # is no top-level `result` wrapper, unlike every other method. Each reply + # in the array corresponds to one command (in the order they were sent + # when `parallel` is not set) and has the shape `{ "" => }` + # on success or `{ "error" => { "code" => ..., "message" => ... } }` on a + # per-command failure. + # + # These per-command errors are **not** raised as {Cent::ResponseError} — + # that would make partial-failure responses impossible to inspect. The + # caller is expected to walk `response["replies"]` and check each entry. + # If Centrifugo rejects the batch request as a whole (e.g. malformed + # top-level body), the top-level `error` field is present and + # {Cent::ResponseError} is raised normally. + # + # @example + # response = client.batch(commands: [ + # { 'publish' => { 'channel' => 'a', 'data' => {} } }, + # { 'publish' => { 'channel' => 'unknown:b', 'data' => {} } } + # ]) + # response['replies'].each do |reply| + # if reply['error'] + # warn "command failed: #{reply['error']['code']} #{reply['error']['message']}" + # end + # end + # + # @param commands [Array] Each element is a command object of the + # form `{ "publish" => { ... } }`, `{ "broadcast" => { ... } }`, etc. + # @param parallel [Boolean, nil] When true, Centrifugo processes commands + # in parallel (lower latency, order not guaranteed). + # @see https://centrifugal.dev/docs/server/server_api#batch + def batch(commands:, parallel: nil) + send_command('batch', { + 'commands' => commands, + 'parallel' => parallel + }) end private - def execute(method, data) - body = { method: method, params: data } + def send_command(method, params) + body = connection.post(method, params.compact).body + check_response_error!(body) + body + rescue Faraday::TimeoutError => e + raise Cent::TimeoutError, e.message + rescue Faraday::ConnectionFailed => e + raise Cent::NetworkError, e.message + rescue Faraday::UnauthorizedError => e + raise Cent::UnauthorizedError.new(status: 401, message: e.message) + rescue Faraday::ClientError, Faraday::ServerError => e + status = e.response_status || e.response&.dig(:status) + raise Cent::TransportError.new(status: status, message: e.message) + rescue Faraday::ParsingError => e + raise Cent::DecodeError, e.message + end + + # Top-level `error` means Centrifugo rejected the whole request. Batch + # and broadcast sub-reply errors live inside arrays and are NOT raised — + # callers inspect them manually (see #batch docs). + def check_response_error!(body) + return unless body.is_a?(Hash) && body['error'].is_a?(Hash) - Cent::HTTP.new(connection: @connection).post(body: body) + raise Cent::ResponseError.new( + code: body['error']['code'], + message: body['error']['message'] + ) end end end diff --git a/lib/cent/error.rb b/lib/cent/error.rb index 4f5c00d..f5e311f 100644 --- a/lib/cent/error.rb +++ b/lib/cent/error.rb @@ -1,9 +1,46 @@ # frozen_string_literal: true module Cent - # Cent::Error - # - # Wrapper for all errors(failures). - # + # Base class for all errors raised by this library. class Error < StandardError; end + + # Raised when Centrifugo is unreachable (DNS failure, connection refused, ...). + class NetworkError < Error; end + + # Raised when the HTTP request times out. + class TimeoutError < Error; end + + # Raised when Centrifugo returns a non-2xx HTTP status. + class TransportError < Error + attr_reader :status + + def initialize(status:, message: nil) + @status = status + super(message || "HTTP #{status}") + end + end + + # Raised when Centrifugo returns HTTP 401 (invalid API key). + class UnauthorizedError < TransportError; end + + # Raised when response from Centrifugo cannot be decoded (not valid JSON). + class DecodeError < Error; end + + # Raised when Centrifugo returns a top-level `error` in the response body + # (API-level failure, e.g., unknown channel, namespace not found). Exposes + # Centrifugo's numeric `code` and human-readable `message`. See + # https://centrifugal.dev/docs/server/server_api#error for the full list + # of codes. + # + # Note: for `batch` and `broadcast`, individual sub-reply errors are NOT + # raised — those responses contain an array of independent replies, and + # each entry should be inspected by the caller for its own `error` key. + class ResponseError < Error + attr_reader :code + + def initialize(code:, message:) + @code = code + super(message) + end + end end diff --git a/lib/cent/http.rb b/lib/cent/http.rb deleted file mode 100644 index b665c3f..0000000 --- a/lib/cent/http.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -require 'cent/error' - -module Cent - # Cent::ResponseError - # - # Raised when response from Centrifugo contains any error as result of API command execution. - # - class ResponseError < Error - attr_reader :code - - def initialize(code:, message:) - @code = code - super(message) - end - end - - # Cent::HTTP - # - # Holds request call and response handling logic - # - class HTTP - attr_reader :connection - - # @param connection [Faraday::Connection] HTTP Connection object - # - def initialize(connection:) - @connection = connection - end - - # Perform POST request to centrifugo API - # @param body [Hash] Request body(non serialized) - # - # @raise [Cent::ResponseError] - # - # @return [Hash] Parsed response body - # - def post(body: nil) - response = connection.post(nil, body) - - raise ResponseError.new(**response.body['error'].transform_keys(&:to_sym)) if response.body.key?('error') - - response.body - end - end -end diff --git a/lib/cent/notary.rb b/lib/cent/notary.rb index 99f279b..553130e 100644 --- a/lib/cent/notary.rb +++ b/lib/cent/notary.rb @@ -6,84 +6,93 @@ module Cent # Cent::Notary # - # Handle token generation + # Issues JWT tokens for Centrifugo client connections and channel subscriptions. + # Supports HMAC, RSA and ECDSA families of algorithms (HS256/384/512, + # RS256/384/512, ES256/384/512). # + # @see https://centrifugal.dev/docs/server/authentication + # @see https://centrifugal.dev/docs/server/channel_token_auth class Notary - # @param secret [String | OpenSSL::PKey::RSA | OpenSSL::PKey::EC] Secret key for the algorithm of your choice. - # @param algorithm [String] Specify algorithm(one from HMAC, RSA or ECDSA family). Default is HS256. - # - # @example Construct new client instance - # notary = Cent::Notary.new(secret: 'secret') - # + # @param secret [String, OpenSSL::PKey::RSA, OpenSSL::PKey::EC] Secret key + # for the chosen algorithm. For HMAC pass the raw secret as a String. For + # RSA/ECDSA pass a PEM-loaded {OpenSSL::PKey::RSA} / {OpenSSL::PKey::EC}. + # @param algorithm [String] JWT algorithm, defaults to `HS256`. def initialize(secret:, algorithm: 'HS256') raise Error, 'Secret can not be nil' if secret.nil? - @secret = secret + @secret = secret @algorithm = algorithm end - # Generate connection JWT for the given user - # - # @param sub [String] - # Standard JWT claim which must contain an ID of current application user. - # - # @option channel [String] - # Channel that client tries to subscribe to (string). - # - # @param exp [Integer] - # (default: nil) UNIX timestamp seconds when token will expire. - # - # @param info [Hash] - # (default: {}) This claim is optional - this is additional information about - # client connection that can be provided for Centrifugo. - # - # @example Get user JWT with expiration and extra info - # notary.issue_connection_token(sub: '1', exp: 3600, info: { 'role' => 'admin' }) - # #=> "eyJhbGciOiJIUzI1NiJ9.eyJzdWIi..." - # - # @see (https://centrifugal.github.io/centrifugo/server/authentication/) - # - # @return [String] - # - def issue_connection_token(sub:, info: nil, exp: nil) + # Issue a connection JWT used by clients when establishing a real-time + # connection to Centrifugo. + # + # @param sub [String] Standard JWT claim with the application user ID. + # Use an empty string for anonymous connections. + # @param exp [Integer] UNIX timestamp (seconds) when the token expires. + # @param iat [Integer] UNIX timestamp (seconds) when the token was issued. + # @param jti [String] Unique token identifier. + # @param aud [String] Token audience (matches `client.token.audience`). + # @param iss [String] Token issuer (matches `client.token.issuer`). + # @param info [Hash] Arbitrary public info attached to the connection. + # @param b64info [String] Base64-encoded `info` (for binary payloads). + # @param channels [Array] Server-side subscription channel list. + # @param subs [Hash] Server-side subscriptions with per-channel options. + # @param meta [Hash] Server-only metadata attached to the connection. + # @param expire_at [Integer] Override connection expiration timestamp. + # + # @return [String] Encoded JWT. + def issue_connection_token(sub:, exp: nil, iat: nil, jti: nil, aud: nil, iss: nil, + info: nil, b64info: nil, channels: nil, subs: nil, + meta: nil, expire_at: nil) payload = { 'sub' => sub, + 'exp' => exp, + 'iat' => iat, + 'jti' => jti, + 'aud' => aud, + 'iss' => iss, 'info' => info, - 'exp' => exp + 'b64info' => b64info, + 'channels' => channels, + 'subs' => subs, + 'meta' => meta, + 'expire_at' => expire_at }.compact JWT.encode(payload, secret, algorithm) end - # Generate JWT for private channels - # - # @param sub [String] - # Standard JWT claim which must contain an ID of current application user. - # - # @option channel [String] - # Channel that client tries to subscribe to (string). - # - # @param exp [Integer] - # (default: nil) UNIX timestamp seconds when token will expire. - # - # @param info [Hash] - # (default: {}) This claim is optional - this is additional information about - # client connection that can be provided for Centrifugo. - # - # @example Get private channel JWT with expiration and extra info - # notary.issue_channel_token(sub: '1', channel: 'channel', exp: 3600, info: { 'message' => 'wat' }) - # #=> eyJhbGciOiJIUzI1NiJ9.eyJjbGllbnQiOiJjbG..." - # - # @see (https://centrifugal.github.io/centrifugo/server/private_channels/) - # - # @return [String] - # - def issue_channel_token(sub:, channel:, info: nil, exp: nil) + # Issue a subscription JWT used by clients to authorize subscription to a + # channel that requires token authorization. + # + # @param sub [String] Application user ID (same meaning as in connection token). + # @param channel [String] Channel this subscription token is valid for. + # @param exp [Integer] UNIX timestamp (seconds) when the token expires. + # @param iat [Integer] UNIX timestamp (seconds) when the token was issued. + # @param jti [String] Unique token identifier. + # @param aud [String] Token audience. + # @param iss [String] Token issuer. + # @param info [Hash] Arbitrary channel info. + # @param b64info [String] Base64-encoded `info`. + # @param override [Hash] Per-subscription channel option overrides. + # @param expire_at [Integer] Override subscription expiration timestamp. + # + # @return [String] Encoded JWT. + def issue_channel_token(sub:, channel:, exp: nil, iat: nil, jti: nil, aud: nil, iss: nil, + info: nil, b64info: nil, override: nil, expire_at: nil) payload = { 'sub' => sub, 'channel' => channel, + 'exp' => exp, + 'iat' => iat, + 'jti' => jti, + 'aud' => aud, + 'iss' => iss, 'info' => info, - 'exp' => exp + 'b64info' => b64info, + 'override' => override, + 'expire_at' => expire_at }.compact JWT.encode(payload, secret, algorithm) diff --git a/lib/cent/version.rb b/lib/cent/version.rb index 0531972..3c524cc 100644 --- a/lib/cent/version.rb +++ b/lib/cent/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Cent - VERSION = '3.0.0' + VERSION = '4.0.0' end diff --git a/spec/cent/client_spec.rb b/spec/cent/client_spec.rb index df8ecd0..deae058 100644 --- a/spec/cent/client_spec.rb +++ b/spec/cent/client_spec.rb @@ -3,301 +3,230 @@ require 'spec_helper' RSpec.describe Cent::Client do - let(:channel) { 'chat' } - let(:expected_body) { '{}' } - let(:client) { described_class.new(api_key: 'api_key') } + subject(:client) { described_class.new(api_key: 'api_key') } - before do - request_headers = { - 'Content-Type' => 'application/json', - 'Authorization' => 'apikey api_key' - } - response_headers = { 'Content-Type' => 'application/json' } - stub_request(:post, 'http://localhost:8000/api') - .with(body: params, headers: request_headers) - .to_return(status: 200, body: expected_body, headers: response_headers) + let(:api_endpoint) { 'http://localhost:8000/api' } + let(:expected_headers) do + { 'Content-Type' => 'application/json', 'X-API-Key' => 'api_key' } end - describe 'error handling' do - subject(:response) { client.history(channel: channel) } - - let(:expected_body) { '{"error": { "code": 108, "message": "not available"}}' } - let(:data) { { content: 'wat' } } - let(:params) do - { - method: 'history', - params: { channel: channel } - } - end - - it do - expect { response } - .to raise_exception( - an_instance_of(Cent::ResponseError).and(have_attributes(code: 108, message: 'not available')) - ) - end + def stub_method(method, request_body:, response_body: '{"result":{}}', status: 200) + stub_request(:post, "#{api_endpoint}/#{method}") + .with(body: request_body, headers: expected_headers) + .to_return(status: status, body: response_body, headers: { 'Content-Type' => 'application/json' }) end describe '#publish' do - subject(:response) { client.publish(channel: channel, data: data) } - - let(:data) { { content: 'wat' } } - - let(:params) do - { - method: 'publish', - params: { channel: channel, data: data } - } + it 'posts to /publish with channel and data' do + stub = stub_method('publish', request_body: { 'channel' => 'chat', 'data' => { 'content' => 'hi' } }) + expect(client.publish(channel: 'chat', data: { content: 'hi' })).to eq('result' => {}) + expect(stub).to have_been_requested + end + + it 'omits nil kwargs and passes the rest through' do + stub_method('publish', + request_body: { + 'channel' => 'chat', + 'data' => { 'x' => 1 }, + 'skip_history' => true, + 'tags' => { 'k' => 'v' }, + 'idempotency_key' => 'k1', + 'delta' => true + }) + client.publish(channel: 'chat', data: { x: 1 }, skip_history: true, + tags: { k: 'v' }, idempotency_key: 'k1', delta: true) end - - it { is_expected.to eq({}) } end describe '#broadcast' do - subject(:response) { client.broadcast(channels: [channel], data: data) } - - let(:data) { { content: 'wat' } } - - let(:params) do - { - method: 'broadcast', - params: { channels: [channel], data: data } - } + it 'posts to /broadcast with channels array' do + stub_method('broadcast', + request_body: { 'channels' => %w[a b], 'data' => { 'x' => 1 } }) + client.broadcast(channels: %w[a b], data: { x: 1 }) end + end - it { is_expected.to eq({}) } + describe '#subscribe' do + it 'posts to /subscribe with user and channel' do + stub_method('subscribe', request_body: { 'user' => '42', 'channel' => 'chat' }) + client.subscribe(user: '42', channel: 'chat') + end end describe '#unsubscribe' do - subject(:response) { client.unsubscribe(channel: channel, user: 1) } - - let(:params) do - { - method: 'unsubscribe', - params: { channel: channel, user: 1 } - } + it 'posts to /unsubscribe' do + stub_method('unsubscribe', request_body: { 'user' => '42', 'channel' => 'chat' }) + client.unsubscribe(user: '42', channel: 'chat') end - - it { is_expected.to eq({}) } end describe '#disconnect' do - subject(:response) { client.disconnect(user: 1) } - - let(:params) do - { - method: 'disconnect', - params: { user: 1 } - } + it 'posts to /disconnect' do + stub_method('disconnect', request_body: { 'user' => '42' }) + client.disconnect(user: '42') + end + + it 'supports client, session, whitelist and disconnect object' do + stub_method('disconnect', + request_body: { + 'user' => '42', + 'client' => 'c1', + 'session' => 's1', + 'whitelist' => %w[c9], + 'disconnect' => { 'code' => 4000, 'reason' => 'bye' } + }) + client.disconnect(user: '42', client: 'c1', session: 's1', whitelist: %w[c9], + disconnect: { 'code' => 4000, 'reason' => 'bye' }) end - - it { is_expected.to eq({}) } end - describe 'presence' do - let(:expected_body) do - '{ - "result": { - "presence": { - "c54313b2-0442-499a-a70c-051f8588020f": { - "client": "c54313b2-0442-499a-a70c-051f8588020f", - "user": "42" - } - } - } - }' + describe '#refresh' do + it 'posts to /refresh' do + stub_method('refresh', request_body: { 'user' => '42', 'expired' => true }) + client.refresh(user: '42', expired: true) end + end - describe '#presence' do - subject(:response) { client.presence(channel: channel) } - - let(:params) do - { - method: 'presence', - params: { channel: channel } - } - end - - it 'returns hash with channel presence information' do - expected_hash = { - 'result' => { - 'presence' => { - 'c54313b2-0442-499a-a70c-051f8588020f' => { - 'client' => 'c54313b2-0442-499a-a70c-051f8588020f', - 'user' => '42' - } - } - } - } - - expect(response).to eq(expected_hash) - end + describe '#presence' do + it 'posts to /presence' do + stub_method('presence', + request_body: { 'channel' => 'chat' }, + response_body: '{"result":{"presence":{}}}') + expect(client.presence(channel: 'chat')).to eq('result' => { 'presence' => {} }) end end - describe 'presence_stats' do - let(:expected_body) do - '{ - "result": { - "num_clients": 0, - "num_users": 0 - } - }' + describe '#presence_stats' do + it 'posts to /presence_stats' do + stub_method('presence_stats', + request_body: { 'channel' => 'chat' }, + response_body: '{"result":{"num_clients":0,"num_users":0}}') + expect(client.presence_stats(channel: 'chat')).to eq( + 'result' => { 'num_clients' => 0, 'num_users' => 0 } + ) end + end - describe '#presence_stats' do - subject(:response) { client.presence_stats(channel: channel) } - - let(:params) do - { - method: 'presence_stats', - params: { channel: channel } - } - end - - it 'returns hash with channel presence_stats information' do - expected_hash = { - 'result' => { - 'num_clients' => 0, - 'num_users' => 0 - } - } - - expect(response).to eq(expected_hash) - end + describe '#history' do + it 'posts to /history with optional limit/since/reverse' do + stub_method('history', + request_body: { 'channel' => 'chat', 'limit' => 10, 'reverse' => true }) + client.history(channel: 'chat', limit: 10, reverse: true) end end - describe 'history' do - let(:expected_body) do - '{ - "result": { - "publications": [ - { - "data": { - "text": "hello" - }, - "uid": "BWcn14OTBrqUhTXyjNg0fg" - } - ] - } - }' + describe '#history_remove' do + it 'posts to /history_remove' do + stub_method('history_remove', request_body: { 'channel' => 'chat' }) + client.history_remove(channel: 'chat') end + end - describe '#history' do - subject(:response) { client.history(channel: channel) } + describe '#channels' do + it 'posts to /channels with optional pattern' do + stub_method('channels', + request_body: { 'pattern' => 'chat:*' }, + response_body: '{"result":{"channels":{}}}') + client.channels(pattern: 'chat:*') + end - let(:params) do - { - method: 'history', - params: { channel: channel } - } - end + it 'defaults to empty body when no pattern' do + stub_method('channels', request_body: {}, response_body: '{"result":{"channels":{}}}') + client.channels + end + end - it 'returns channel history information' do - expected_hash = { - 'result' => { - 'publications' => [ - { - 'data' => { - 'text' => 'hello' - }, - 'uid' => 'BWcn14OTBrqUhTXyjNg0fg' - } - ] - } - } + describe '#info' do + it 'posts to /info with empty body' do + stub_method('info', request_body: {}, response_body: '{"result":{"nodes":[]}}') + expect(client.info).to eq('result' => { 'nodes' => [] }) + end + end - expect(response).to eq(expected_hash) - end + describe '#batch' do + it 'posts to /batch with commands and parallel flag' do + commands = [ + { 'publish' => { 'channel' => 'a', 'data' => {} } }, + { 'publish' => { 'channel' => 'b', 'data' => {} } } + ] + stub_method('batch', + request_body: { 'commands' => commands, 'parallel' => true }, + response_body: '{"replies":[{"publish":{}},{"publish":{}}]}') + expect(client.batch(commands: commands, parallel: true)).to eq( + 'replies' => [{ 'publish' => {} }, { 'publish' => {} }] + ) end end - describe 'channels' do - let(:expected_body) do - '{ - "result": { - "channels": [ - "chat" + describe 'API error responses' do + it 'raises Cent::ResponseError on a top-level error body' do + stub_method('publish', + request_body: { 'channel' => 'x:y', 'data' => {} }, + response_body: '{"error":{"code":102,"message":"unknown channel"}}') + expect { client.publish(channel: 'x:y', data: {}) } + .to raise_error(Cent::ResponseError) do |err| + expect(err.code).to eq(102) + expect(err.message).to eq('unknown channel') + end + end + + it 'does not raise on per-reply errors inside a batch response' do + body = '{"replies":[{"publish":{}},{"error":{"code":102,"message":"unknown channel"}}]}' + stub_method('batch', + request_body: { 'commands' => [{ 'publish' => { 'channel' => 'a' } }] }, + response_body: body) + expect(client.batch(commands: [{ 'publish' => { 'channel' => 'a' } }])).to eq( + 'replies' => [ + { 'publish' => {} }, + { 'error' => { 'code' => 102, 'message' => 'unknown channel' } } ] - } - }' + ) end - describe '#channels' do - subject(:response) { client.channels } + it 'does not raise on per-channel errors inside a broadcast result' do + body = '{"result":{"responses":[{"result":{"offset":1}},{"error":{"code":102,"message":"unknown channel"}}]}}' + stub_method('broadcast', + request_body: { 'channels' => %w[a b], 'data' => {} }, + response_body: body) + response = client.broadcast(channels: %w[a b], data: {}) + expect(response.dig('result', 'responses', 1, 'error', 'code')).to eq(102) + end + end - let(:params) do - { - method: 'channels', - params: {} - } + describe 'transport errors' do + it 'raises Cent::UnauthorizedError on 401' do + stub_method('info', request_body: {}, status: 401, response_body: '') + expect { client.info }.to raise_error(Cent::UnauthorizedError) do |err| + expect(err.status).to eq(401) + expect(err).to be_a(Cent::TransportError) end + end - it 'returns channel history information' do - expected_hash = { - 'result' => { - 'channels' => [ - 'chat' - ] - } - } - - expect(response).to eq(expected_hash) + it 'raises Cent::TransportError on 5xx' do + stub_method('info', request_body: {}, status: 500, response_body: '') + expect { client.info }.to raise_error(Cent::TransportError) do |err| + expect(err.status).to eq(500) end end - end - describe 'info' do - let(:expected_body) do - '{ - "result": { - "nodes": [ - { - "name": "Alexanders-MacBook-Pro.local_8000", - "num_channels": 0, - "num_clients": 0, - "num_users": 0, - "uid": "f844a2ed-5edf-4815-b83c-271974003db9", - "uptime": 0, - "version": "" - } - ] - } - }' + it 'raises Cent::TimeoutError when the adapter raises Faraday::TimeoutError' do + stub_request(:post, "#{api_endpoint}/info").to_raise(Faraday::TimeoutError.new('timed out')) + expect { client.info }.to raise_error(Cent::TimeoutError) end - describe '#info' do - subject(:response) { client.info } - - let(:params) do - { - method: 'info', - params: {} - } - end - - let(:expectation) do - { - 'result' => { - 'nodes' => [ - { - 'name' => 'Alexanders-MacBook-Pro.local_8000', - 'num_channels' => 0, - 'num_clients' => 0, - 'num_users' => 0, - 'uid' => 'f844a2ed-5edf-4815-b83c-271974003db9', - 'uptime' => 0, - 'version' => '' - } - ] - } - } - end + it 'raises Cent::NetworkError on connection failure' do + stub_request(:post, "#{api_endpoint}/info").to_raise(Faraday::ConnectionFailed.new('nope')) + expect { client.info }.to raise_error(Cent::NetworkError) + end + end - it 'returns channel history information' do - expect(response).to eq(expectation) - end + describe 'endpoint without trailing slash' do + it 'still produces /api/ URLs' do + c = described_class.new(api_key: 'api_key', endpoint: 'http://centrifugo.local/api') + stub_request(:post, 'http://centrifugo.local/api/info') + .to_return(status: 200, body: '{"result":{"nodes":[]}}', + headers: { 'Content-Type' => 'application/json' }) + expect(c.info).to eq('result' => { 'nodes' => [] }) end end end diff --git a/spec/cent/notary_spec.rb b/spec/cent/notary_spec.rb index 10b8051..aca0c77 100644 --- a/spec/cent/notary_spec.rb +++ b/spec/cent/notary_spec.rb @@ -3,155 +3,90 @@ require 'spec_helper' RSpec.describe Cent::Notary do + let(:secret) { 'secret' } + let(:notary) { described_class.new(secret: secret) } + + def decode(token, key = secret, algo = 'HS256') + JWT.decode(token, key, true, algorithm: algo, verify_expiration: false).first + end + describe '.new' do - context 'with empty secret' do - it { expect { described_class.new(secret: nil) }.to raise_error(Cent::Error) } + it 'raises when secret is nil' do + expect { described_class.new(secret: nil) }.to raise_error(Cent::Error) end end describe '#issue_connection_token' do - subject(:connection_token) { notary.issue_connection_token(sub: '1') } - - let(:notary) { described_class.new(secret: 'secret') } - - context 'without expiration' do - it { expect(connection_token).to match(/^eyJhbGciOiJIUzI1NiJ9.*uEnHk$/) } + it 'includes only sub when only sub is passed' do + payload = decode(notary.issue_connection_token(sub: '42')) + expect(payload).to eq('sub' => '42') end - context 'with expiration' do - subject(:connection_token) do - notary.issue_connection_token(sub: '1', exp: 1_628_877_060, info: { 'foo' => 'bar' }) - end - - it { expect(connection_token).to match(/^eyJhbGciOiJIUzI1NiJ9.*PErWc8$/) } + it 'includes all supported claims when provided' do + token = notary.issue_connection_token( + sub: '42', exp: 100, iat: 90, jti: 'id1', aud: 'cent', iss: 'app', + info: { 'scope' => 'admin' }, b64info: 'YmFy', + channels: %w[a b], subs: { 'c' => { 'data' => {} } }, + meta: { 'internal' => true }, expire_at: 200 + ) + payload = decode(token) + expect(payload).to eq( + 'sub' => '42', + 'exp' => 100, + 'iat' => 90, + 'jti' => 'id1', + 'aud' => 'cent', + 'iss' => 'app', + 'info' => { 'scope' => 'admin' }, + 'b64info' => 'YmFy', + 'channels' => %w[a b], + 'subs' => { 'c' => { 'data' => {} } }, + 'meta' => { 'internal' => true }, + 'expire_at' => 200 + ) end - context 'with RSA key' do - let(:pem_key) do - <<~PEM_KEY - -----BEGIN RSA PRIVATE KEY----- - MIIEpQIBAAKCAQEAuzo3AZZKBXOKBdNyDSsnrQzzR5gLN/Ps+Bg0pXKxWzKzXR6M - tkWz3EomOCVG2sN9EhmfJ67y3QccrkKi0LokuNgDcjJA9D7Py3fjduN6mSCG9Ecp - pSK+xHm6rN3WI7wg8iynWTX31vhpxyz5ILnAU/S8W/QBsFmoA5EWvRa0gFtx3a5M - RC2ZpNSgkuJiAOVDXJoWVPUWynI3KFEUfEU20Q21clnpGaOdZuEgYMeUUGN1h0d8 - ixnmQ7NUd4NdNjz5U8OaINIi09nwznP21QA+bNUshY9UOB87njVesBf/mqOStjh4 - 7bzYQBJFU3t02qRevLakzC/8HyITp8VNZ4AfKwIDAQABAoIBACYeWRqinZl0h5Je - FWdm9OH/s/xMkWQn7oQocXeJ3WAi92+rC50EnfTox9VAiad6i5lGzCeJL/seOpGk - EYALlfRoTnNOlfjkXOwhEZegAtLwU2min3D2nP5lhkMxuyp1YAPOYZgBK9+Bng+m - MWafSvAM8NiL2lgsOM/ZF1cSK1fCbQc132/up9Me6+coBU7Pmq8eFMbLa6tKAb10 - Vo4F9kbxYcDFeCpnxGRL/eC4nuHlvms7GRcjcK4fgGwrDfO364TCxN5phn98vTLw - /z67YwFs+AO7z2wDK6pUQs9ftkpfeTWgP58IznG9TCEDOw67rYsXuzoqTNMwrqIi - NRqXsSECgYEA7I7dTJJoAqDn+JssRQAmuHgcXvBTw6qMZeFvy/NQTJpK7rm5v79K - ZdP0ZGrjYCHEwJb50iaGXFTr4HBN8vqbDbZ9LZpHHCq6eltZJ1Q7pivgZHdVITZS - Ieb3B1ZaxI5LszIKtnsWiNA3P/wRjpdeckXZGbJAuBAx8Vf8MNyhBzECgYEAyp1x - YdDZ/UgPIhEAB0SvFpeAdkcjKtH4VN2MAEEJjal7JCeOKot8QTkqM/6D/kJenoK4 - wZWWK7fdcv+aEBneEHBHN7jSfvUJp3UAGZXv8O95LR1KskCpoa2TPKtxVkChP6zT - RfGiWUnBxkVVXnbenteAHsJqsK46+3uIbj2c7RsCgYEAtAWc3/Li+G0fW5ArRm9x - CB1P6egWtucJZVcETz9hMoqQz8/DTerzYT7F082ML9JC+xVqFMWApq9xuiF9EJYq - fWsNJDEuQH873nW6CTYPFsx5Pbuaq2W9Z1NvVsQe20o2za4dfPV7Fq7t/OGFMvB6 - zZfeObHvkqOwfiwpHb4pRWECgYEAvwo+Ws1SjLdB1YwT68Z+FB4bOOqQJRK/RD10 - gNTRzilr+0X0jPbh3JmqykWDbNxlXK3CyHxjkKsXeRO5zs6lC/jhnY99ocknJiZy - Rq2SBCm3pqsEwBeqGdCQkFbSUVI099XbiwpvWiLqOykqehw4gaqNmfMUJ6zP3ki2 - 9cLQUNsCgYEAhtBfAYw2pkqOfm7PFGCWoOi5fQg7EPeTyZnMmWFJDZj4TtE1B0Kz - FDYdI+MwQFh76WAZN599LShnRP6Hb1SIRxqkeoelFe88hOaquTJAksHfqsIml2Ya - af2HW4+3cGQbdzakZ4Iy+fIM+timAEZ0dVJKl9/rcMpQeNq4lKDIspQ= - -----END RSA PRIVATE KEY----- - PEM_KEY - end - let(:secret) { OpenSSL::PKey::RSA.new(pem_key) } - let(:notary) { described_class.new(secret: secret, algorithm: 'RS256') } - - it { expect(connection_token).to match(/^eyJhbGciOiJSUzI1NiJ9.*$/) } + it 'supports RSA keys' do + rsa = OpenSSL::PKey::RSA.new(2048) + token = described_class.new(secret: rsa, algorithm: 'RS256') + .issue_connection_token(sub: '1') + expect(decode(token, rsa.public_key, 'RS256')).to eq('sub' => '1') end - context 'with ECDSA key' do - let(:pem_key) do - <<~PEM_KEY - -----BEGIN EC PRIVATE KEY----- - MHcCAQEEIMotgJBpoUge/YyUSRmR+AwK3Ymh5O8w7vhA56nzWjGYoAoGCCqGSM49 - AwEHoUQDQgAENU4BF7Wxnnmp4JP5bRUYXOz51T7Ot/BC83zbhizsgupPqobhDToi - 4udmmyn0Ltnioiw5rRwA6gDvdx83q6+a+w== - -----END EC PRIVATE KEY----- - PEM_KEY - end - let(:secret) { OpenSSL::PKey::EC.new(pem_key) } - let(:notary) { described_class.new(secret: secret, algorithm: 'ES256') } - - it { expect(connection_token).to match(/^eyJhbGciOiJFUzI1NiJ9.*$/) } + it 'supports ECDSA keys' do + ec = OpenSSL::PKey::EC.generate('prime256v1') + token = described_class.new(secret: ec, algorithm: 'ES256') + .issue_connection_token(sub: '1') + expect(decode(token, ec, 'ES256')).to eq('sub' => '1') end end describe '#issue_channel_token' do - subject(:channel_token) do - notary.issue_channel_token(sub: '1', channel: 'channel', info: { 'foo' => 'bar' }) - end - - let(:notary) { described_class.new(secret: 'secret') } - - context 'with no expiration' do - it { expect(channel_token).to match(/^eyJhbGciOiJIUzI1NiJ9.*50TnzY$/) } - end - - context 'with expiration' do - subject(:channel_token) do - notary.issue_channel_token(sub: '1', channel: 'channel', info: { 'foo' => 'bar' }, exp: 1_628_877_060) - end - - it { expect(channel_token).to match(/^eyJhbGciOiJIUzI1NiJ9.*yht5hk$/) } + it 'includes sub and channel by default' do + payload = decode(notary.issue_channel_token(sub: '42', channel: 'chat')) + expect(payload).to eq('sub' => '42', 'channel' => 'chat') end - context 'with RSA key' do - let(:pem_key) do - <<~PEM_KEY - -----BEGIN RSA PRIVATE KEY----- - MIIEpQIBAAKCAQEAuzo3AZZKBXOKBdNyDSsnrQzzR5gLN/Ps+Bg0pXKxWzKzXR6M - tkWz3EomOCVG2sN9EhmfJ67y3QccrkKi0LokuNgDcjJA9D7Py3fjduN6mSCG9Ecp - pSK+xHm6rN3WI7wg8iynWTX31vhpxyz5ILnAU/S8W/QBsFmoA5EWvRa0gFtx3a5M - RC2ZpNSgkuJiAOVDXJoWVPUWynI3KFEUfEU20Q21clnpGaOdZuEgYMeUUGN1h0d8 - ixnmQ7NUd4NdNjz5U8OaINIi09nwznP21QA+bNUshY9UOB87njVesBf/mqOStjh4 - 7bzYQBJFU3t02qRevLakzC/8HyITp8VNZ4AfKwIDAQABAoIBACYeWRqinZl0h5Je - FWdm9OH/s/xMkWQn7oQocXeJ3WAi92+rC50EnfTox9VAiad6i5lGzCeJL/seOpGk - EYALlfRoTnNOlfjkXOwhEZegAtLwU2min3D2nP5lhkMxuyp1YAPOYZgBK9+Bng+m - MWafSvAM8NiL2lgsOM/ZF1cSK1fCbQc132/up9Me6+coBU7Pmq8eFMbLa6tKAb10 - Vo4F9kbxYcDFeCpnxGRL/eC4nuHlvms7GRcjcK4fgGwrDfO364TCxN5phn98vTLw - /z67YwFs+AO7z2wDK6pUQs9ftkpfeTWgP58IznG9TCEDOw67rYsXuzoqTNMwrqIi - NRqXsSECgYEA7I7dTJJoAqDn+JssRQAmuHgcXvBTw6qMZeFvy/NQTJpK7rm5v79K - ZdP0ZGrjYCHEwJb50iaGXFTr4HBN8vqbDbZ9LZpHHCq6eltZJ1Q7pivgZHdVITZS - Ieb3B1ZaxI5LszIKtnsWiNA3P/wRjpdeckXZGbJAuBAx8Vf8MNyhBzECgYEAyp1x - YdDZ/UgPIhEAB0SvFpeAdkcjKtH4VN2MAEEJjal7JCeOKot8QTkqM/6D/kJenoK4 - wZWWK7fdcv+aEBneEHBHN7jSfvUJp3UAGZXv8O95LR1KskCpoa2TPKtxVkChP6zT - RfGiWUnBxkVVXnbenteAHsJqsK46+3uIbj2c7RsCgYEAtAWc3/Li+G0fW5ArRm9x - CB1P6egWtucJZVcETz9hMoqQz8/DTerzYT7F082ML9JC+xVqFMWApq9xuiF9EJYq - fWsNJDEuQH873nW6CTYPFsx5Pbuaq2W9Z1NvVsQe20o2za4dfPV7Fq7t/OGFMvB6 - zZfeObHvkqOwfiwpHb4pRWECgYEAvwo+Ws1SjLdB1YwT68Z+FB4bOOqQJRK/RD10 - gNTRzilr+0X0jPbh3JmqykWDbNxlXK3CyHxjkKsXeRO5zs6lC/jhnY99ocknJiZy - Rq2SBCm3pqsEwBeqGdCQkFbSUVI099XbiwpvWiLqOykqehw4gaqNmfMUJ6zP3ki2 - 9cLQUNsCgYEAhtBfAYw2pkqOfm7PFGCWoOi5fQg7EPeTyZnMmWFJDZj4TtE1B0Kz - FDYdI+MwQFh76WAZN599LShnRP6Hb1SIRxqkeoelFe88hOaquTJAksHfqsIml2Ya - af2HW4+3cGQbdzakZ4Iy+fIM+timAEZ0dVJKl9/rcMpQeNq4lKDIspQ= - -----END RSA PRIVATE KEY----- - PEM_KEY - end - let(:secret) { OpenSSL::PKey::RSA.new(pem_key) } - let(:notary) { described_class.new(secret: secret, algorithm: 'RS256') } - - it { expect(channel_token).to match(/^eyJhbGciOiJSUzI1NiJ9.*$/) } - end - - context 'with ECDSA key' do - let(:pem_key) do - <<~PEM_KEY - -----BEGIN EC PRIVATE KEY----- - MHcCAQEEIMotgJBpoUge/YyUSRmR+AwK3Ymh5O8w7vhA56nzWjGYoAoGCCqGSM49 - AwEHoUQDQgAENU4BF7Wxnnmp4JP5bRUYXOz51T7Ot/BC83zbhizsgupPqobhDToi - 4udmmyn0Ltnioiw5rRwA6gDvdx83q6+a+w== - -----END EC PRIVATE KEY----- - PEM_KEY - end - let(:secret) { OpenSSL::PKey::EC.new(pem_key) } - let(:notary) { described_class.new(secret: secret, algorithm: 'ES256') } - - it { expect(channel_token).to match(/^eyJhbGciOiJFUzI1NiJ9.*$/) } + it 'includes all supported claims when provided' do + token = notary.issue_channel_token( + sub: '42', channel: 'chat', exp: 100, iat: 90, jti: 'id1', aud: 'cent', + iss: 'app', info: { 'role' => 'writer' }, b64info: 'YmFy', + override: { 'presence' => { 'value' => true } }, expire_at: 200 + ) + payload = decode(token) + expect(payload).to eq( + 'sub' => '42', + 'channel' => 'chat', + 'exp' => 100, + 'iat' => 90, + 'jti' => 'id1', + 'aud' => 'cent', + 'iss' => 'app', + 'info' => { 'role' => 'writer' }, + 'b64info' => 'YmFy', + 'override' => { 'presence' => { 'value' => true } }, + 'expire_at' => 200 + ) end end end diff --git a/spec/cent_spec.rb b/spec/cent_spec.rb index 909603b..233fbde 100644 --- a/spec/cent_spec.rb +++ b/spec/cent_spec.rb @@ -1,37 +1,21 @@ # frozen_string_literal: true +require 'spec_helper' + RSpec.describe Cent do it 'has a version number' do expect(Cent::VERSION).not_to be_nil end describe 'Cent::Client' do - let(:client) do - Cent::Client.new(api_key: 'api_key', endpoint: 'https://centrifu.go/api') do |c| - c.options.open_timeout = 15 - c.options.timeout = 15 - c.headers['User-Agent'] = 'Centrifugo API V2 Ruby Client' - c.adapter :test do |stub| - info_body = { method: 'info', params: {} }.to_json - info_headers = { - 'Content-Type' => 'application/json', - 'Authorization' => 'apikey api_key', - 'User-Agent' => 'Centrifugo API V2 Ruby Client' - } - - stub.post('/api', info_body, info_headers) do |_env| - [ - 200, - { 'Content-Type': 'application/json' }, - '{}' - ] - end - end + it 'yields the Faraday connection for customization' do + yielded = nil + Cent::Client.new(api_key: 'k') do |conn| + yielded = conn + conn.headers['User-Agent'] = 'test-agent' end - end - - it 'supports connection configuration' do - expect(client.info).to eq({}) + expect(yielded).to be_a(Faraday::Connection) + expect(yielded.headers['User-Agent']).to eq('test-agent') end end end diff --git a/spec/integration/client_integration_spec.rb b/spec/integration/client_integration_spec.rb new file mode 100644 index 0000000..3c38d01 --- /dev/null +++ b/spec/integration/client_integration_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' + +# These tests exercise a real Centrifugo server. They are skipped unless +# CENTRIFUGO_API_URL (and optionally CENTRIFUGO_API_KEY) are set — typically +# by `docker compose up -d` or in CI. +RSpec.describe Cent::Client, :integration do + before do + WebMock.disable_net_connect!(allow_localhost: true) + end + + let(:client) do + described_class.new( + api_key: ENV.fetch('CENTRIFUGO_API_KEY', 'api_key'), + endpoint: ENV.fetch('CENTRIFUGO_API_URL') + ) + end + + # Each test uses its own random channel so parallel runs don't collide. + let(:channel) { "cent-rb-test-#{SecureRandom.hex(6)}" } + + describe '#info' do + it 'returns at least one node' do + response = client.info + expect(response).to have_key('result') + expect(response.dig('result', 'nodes')).to be_an(Array).and(be_any) + end + end + + describe '#publish + #history' do + it 'round-trips a publication through channel history' do + publish_resp = client.publish(channel: channel, data: { 'text' => 'hello' }) + expect(publish_resp).to have_key('result') + expect(publish_resp.dig('result', 'offset')).to be > 0 + + history_resp = client.history(channel: channel, limit: 10) + pubs = history_resp.dig('result', 'publications') + expect(pubs).to be_an(Array).and have_attributes(size: 1) + expect(pubs.first['data']).to eq('text' => 'hello') + end + end + + describe '#broadcast' do + it 'publishes the same data into multiple channels' do + channels = [channel, "#{channel}-2"] + response = client.broadcast(channels: channels, data: { 'ping' => true }) + responses = response.dig('result', 'responses') + expect(responses).to be_an(Array).and have_attributes(size: 2) + responses.each do |r| + expect(r.dig('result', 'offset')).to be > 0 + end + end + end + + describe '#history_remove' do + it 'empties publication list for a channel' do + client.publish(channel: channel, data: { 'x' => 1 }) + client.history_remove(channel: channel) + history_resp = client.history(channel: channel, limit: 10) + expect(history_resp.dig('result', 'publications') || []).to be_empty + end + end + + describe '#presence and #presence_stats' do + it 'returns empty presence for a channel with no subscribers' do + # Ensure channel is active by publishing once (publication alone does + # not create a subscriber, but exercises the channel options). + client.publish(channel: channel, data: {}) + presence = client.presence(channel: channel) + expect(presence.dig('result', 'presence')).to eq({}) + + stats = client.presence_stats(channel: channel) + expect(stats.dig('result', 'num_clients')).to eq(0) + expect(stats.dig('result', 'num_users')).to eq(0) + end + end + + describe '#channels' do + it 'returns a channels map' do + response = client.channels + expect(response.dig('result', 'channels')).to be_a(Hash) + end + end + + describe '#batch' do + it 'executes multiple commands and returns replies in order' do + response = client.batch(commands: [ + { 'publish' => { 'channel' => channel, 'data' => { 'n' => 1 } } }, + { 'publish' => { 'channel' => channel, 'data' => { 'n' => 2 } } }, + { 'presence_stats' => { 'channel' => channel } } + ]) + # Batch response is shaped `{ "replies": [...] }` with no top-level + # `result` wrapper, unlike every other API method. + replies = response['replies'] + expect(replies).to be_an(Array).and have_attributes(size: 3) + expect(replies[0]).to have_key('publish') + expect(replies[1]).to have_key('publish') + expect(replies[2]).to have_key('presence_stats') + end + end + + describe 'Centrifugo API error' do + it 'raises Cent::ResponseError on a top-level error response' do + expect { client.publish(channel: 'unknown_ns:chat', data: {}) } + .to raise_error(Cent::ResponseError) do |err| + expect(err.code).to be_a(Integer) + expect(err.message).to be_a(String) + end + end + + it 'does not raise when a batch sub-reply carries an error' do + response = client.batch(commands: [ + { 'publish' => { 'channel' => channel, 'data' => { 'ok' => true } } }, + { 'publish' => { 'channel' => 'unknown_ns:x', 'data' => {} } } + ]) + replies = response['replies'] + expect(replies).to be_an(Array).and have_attributes(size: 2) + expect(replies[0]).to have_key('publish') + expect(replies[1]).to have_key('error') + end + end + + describe 'invalid API key' do + it 'raises Cent::UnauthorizedError' do + bad = described_class.new(api_key: 'definitely-wrong', endpoint: ENV.fetch('CENTRIFUGO_API_URL')) + expect { bad.info }.to raise_error(Cent::UnauthorizedError) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 1021ffa..03ad57e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,13 +4,15 @@ require 'webmock/rspec' RSpec.configure do |config| - # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = '.rspec_status' - - # Disable RSpec exposing methods globally on `Module` and `main` config.disable_monkey_patching! config.expect_with :rspec do |c| c.syntax = :expect end + + # Integration tests (spec/integration/**) only run when pointed at a real + # Centrifugo instance. Locally: `docker compose up -d` then + # `CENTRIFUGO_API_URL=http://localhost:8000/api CENTRIFUGO_API_KEY=api_key bundle exec rspec`. + config.filter_run_excluding(integration: true) unless ENV['CENTRIFUGO_API_URL'] end From 66c22ec02bd6a26a58645dc24b0f0d07bf1dfa17 Mon Sep 17 00:00:00 2001 From: FZambia Date: Fri, 24 Apr 2026 20:50:03 +0300 Subject: [PATCH 2/5] better CI --- .github/workflows/main.yml | 17 ++++++----------- docker-compose.yml | 2 -- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0ed60b3..fd39c1b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -46,15 +46,7 @@ jobs: - uses: actions/checkout@v4 - name: Start Centrifugo - run: | - docker run -d --name centrifugo \ - -p 8000:8000 -p 10000:10000 \ - -e CENTRIFUGO_HTTP_API_KEY="api_key" \ - -e CENTRIFUGO_CHANNEL_WITHOUT_NAMESPACE_HISTORY_TTL="300s" \ - -e CENTRIFUGO_CHANNEL_WITHOUT_NAMESPACE_HISTORY_SIZE="100" \ - -e CENTRIFUGO_CHANNEL_WITHOUT_NAMESPACE_PRESENCE="true" \ - -e CENTRIFUGO_GRPC_API_ENABLED="true" \ - centrifugo/centrifugo:v6.7.1 centrifugo + run: docker compose up -d - uses: ruby/setup-ruby@v1 with: @@ -64,12 +56,15 @@ jobs: - name: Wait for Centrifugo run: | for i in $(seq 1 30); do - if curl -sf http://localhost:8000/health >/dev/null; then + if curl -sf -X POST -H 'X-API-Key: api_key' -H 'Content-Type: application/json' \ + -d '{}' http://localhost:8000/api/info >/dev/null; then echo "Centrifugo ready"; exit 0 fi sleep 1 done - echo "Centrifugo did not become healthy"; docker logs centrifugo; exit 1 + echo "Centrifugo did not become healthy" + docker compose logs centrifugo + exit 1 - name: Run tests run: bundle exec rspec diff --git a/docker-compose.yml b/docker-compose.yml index c444672..5bf429a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,6 @@ services: command: centrifugo ports: - "8000:8000" - - "10000:10000" ulimits: nofile: soft: 65536 @@ -14,4 +13,3 @@ services: CENTRIFUGO_CHANNEL_WITHOUT_NAMESPACE_HISTORY_TTL: 300s CENTRIFUGO_CHANNEL_WITHOUT_NAMESPACE_HISTORY_SIZE: 100 CENTRIFUGO_CHANNEL_WITHOUT_NAMESPACE_PRESENCE: "true" - CENTRIFUGO_GRPC_API_ENABLED: "true" From acfe7378d22ba12d4fc8865194e5ae976509e7e9 Mon Sep 17 00:00:00 2001 From: FZambia Date: Fri, 24 Apr 2026 20:55:01 +0300 Subject: [PATCH 3/5] fixing CI --- .github/workflows/main.yml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fd39c1b..c2cb829 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,26 +15,22 @@ jobs: - run: bundle exec rubocop test: - # Matrix cross-product: Ruby × gemfile. Faraday 3 and ruby-head cells are - # marked experimental — they don't block CI until those releases stabilise. + # Faraday 3 gemfiles exist under gemfiles/ for forward-readiness and can be + # run locally via `bundle exec appraisal rspec`. They're not wired into CI + # until Faraday 3 is published to RubyGems. The ruby-head cell tracks Ruby + # master (including future Ruby 4) and is marked experimental. name: Test (Ruby ${{ matrix.ruby }} / ${{ matrix.gemfile }}) runs-on: ubuntu-latest continue-on-error: ${{ matrix.experimental == true }} strategy: fail-fast: false matrix: - ruby: ['3.4'] + ruby: ['3.4', '4.0'] gemfile: - gemfiles/faraday2_jwt2.gemfile - gemfiles/faraday2_jwt3.gemfile experimental: [false] include: - - ruby: '3.4' - gemfile: gemfiles/faraday3_jwt2.gemfile - experimental: true - - ruby: '3.4' - gemfile: gemfiles/faraday3_jwt3.gemfile - experimental: true - ruby: 'head' gemfile: gemfiles/faraday2_jwt3.gemfile experimental: true From 3b54b18f17049986d18f38871469161eae2be45b Mon Sep 17 00:00:00 2001 From: FZambia Date: Sat, 25 Apr 2026 11:46:56 +0300 Subject: [PATCH 4/5] readme tweaks --- README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a01f43f..71af02c 100644 --- a/README.md +++ b/README.md @@ -222,18 +222,15 @@ notary.issue_channel_token( ## Migrating from v3 -v4 is a breaking release. Expect to touch a few call sites. +v4 changes some aspects of the library. We expect smooth migration for happy path though. -- **Centrifugo v4+ is required.** v3 of this gem spoke the legacy `POST /api` JSON-RPC-style protocol; v4 uses the current per-method endpoints (`POST /api/publish`, `POST /api/broadcast`, …) and sends the API key as `X-API-Key` instead of `Authorization: apikey `. +- **Centrifugo v5+ is required.** v3 of this gem spoke the legacy `POST /api` JSON-RPC-style protocol; v4 uses the current per-method endpoints (`POST /api/publish`, `POST /api/broadcast`, …) and sends the API key as `X-API-Key` instead of `Authorization: apikey `. - **Error handling is unchanged for the common case.** `Cent::ResponseError` still exists and is still raised when Centrifugo returns a top-level API error. Existing `rescue Cent::ResponseError => e` blocks using `e.code` / `e.message` keep working. The new additions are typed transport errors — `Cent::TimeoutError`, `Cent::NetworkError`, `Cent::TransportError`, `Cent::UnauthorizedError`, `Cent::DecodeError` — all subclassed under `Cent::Error`. - **Keyword arg rename**: `Cent::Notary#issue_channel_token(client:)` → `issue_channel_token(sub:)` to match Centrifugo's standard `sub` JWT claim. -- **`unsubscribe` takes `user:`** now (was previously `user:` too, but is now validated and paired with `channel:`). -- **Ruby 3.0+** is required (was 2.5+). Faraday 2 and Faraday 3 are both supported; JWT 2 and JWT 3 are both supported. +- **Ruby 3.0+** is required (was 2.5+). - **New methods** added for common Centrifugo operations: `subscribe`, `refresh`, `history_remove`, `batch`. - **Richer kwargs** on existing methods (e.g. `publish` now accepts `tags`, `skip_history`, `idempotency_key`, `delta`, `version`, `version_epoch`, `b64data`; `history` accepts `limit`, `since`, `reverse`; `channels` accepts `pattern`). -See `release_v4.0.0.md` for the full list of changes. - ## Development ```sh From 0352ee3260f58daa683176824c14f945b6ae1a88 Mon Sep 17 00:00:00 2001 From: FZambia Date: Sat, 25 Apr 2026 11:49:44 +0300 Subject: [PATCH 5/5] extend ruby versions --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c2cb829..876b5b3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: ['3.4', '4.0'] + ruby: ['3.2', '3.3', '3.4', '4.0'] gemfile: - gemfiles/faraday2_jwt2.gemfile - gemfiles/faraday2_jwt3.gemfile