From 154418a4b13a2478d31fbe12542b3f6eef3bc67f Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 14 Aug 2026 13:51:38 +0100 Subject: [PATCH] Pub/Sub: split the SDK into server and device gems Add ably-pubsub-server and ably-pubsub-device, two thin gems that add Ably::PubSub::Server and Ably::PubSub::Device to the namespace the ably gem provides, so that the gem an application installs names the side it runs on. Their factories return the core's clients unchanged, and they pin the core exactly, releasing in lockstep with it. Mirrors the split made in ably-python (ably/ably-python#681). Constructing Ably::Rest::Client or Ably::Realtime::Client directly now warns, naming the factory to migrate to. Ruby has no equivalent of Python's DeprecationWarning filtering, so Ably::Util::Deprecation warns once per call site and attributes the warning to the first frame outside the SDK; the factories suppress it, their callers having nothing to migrate. Neither Pub/Sub gem may define lib/ably/pubsub.rb: both would ship it and the load path would pick one, hiding the other's. Specs cover that, what each gem ships, that the three versions stay in lockstep, and the factory and deprecation behaviour. A new CI job builds all three gems. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/check.yml | 19 ++++ .yardopts | 1 + CONTRIBUTING.md | 35 +++++- Gemfile | 5 + README.md | 31 +++++- Rakefile | 28 +++++ ably.gemspec | 4 +- lib/ably/realtime.rb | 4 + lib/ably/realtime/client.rb | 15 +++ lib/ably/rest.rb | 3 + lib/ably/rest/client.rb | 15 +++ lib/ably/util/deprecation.rb | 87 +++++++++++++++ packages/ably-pubsub-device/README.md | 48 ++++++++ .../ably-pubsub-device.gemspec | 25 +++++ .../lib/ably/pubsub/device.rb | 48 ++++++++ .../lib/ably/pubsub/device/version.rb | 8 ++ packages/ably-pubsub-server/README.md | 60 ++++++++++ .../ably-pubsub-server.gemspec | 27 +++++ .../lib/ably/pubsub/server.rb | 68 ++++++++++++ .../lib/ably/pubsub/server/version.rb | 8 ++ spec/rspec_config.rb | 11 ++ spec/unit/pubsub/device_spec.rb | 52 +++++++++ spec/unit/pubsub/packaging_spec.rb | 71 ++++++++++++ spec/unit/pubsub/server_spec.rb | 69 ++++++++++++ spec/unit/util/deprecation_spec.rb | 103 ++++++++++++++++++ 25 files changed, 837 insertions(+), 8 deletions(-) create mode 100644 .yardopts create mode 100644 lib/ably/util/deprecation.rb create mode 100644 packages/ably-pubsub-device/README.md create mode 100644 packages/ably-pubsub-device/ably-pubsub-device.gemspec create mode 100644 packages/ably-pubsub-device/lib/ably/pubsub/device.rb create mode 100644 packages/ably-pubsub-device/lib/ably/pubsub/device/version.rb create mode 100644 packages/ably-pubsub-server/README.md create mode 100644 packages/ably-pubsub-server/ably-pubsub-server.gemspec create mode 100644 packages/ably-pubsub-server/lib/ably/pubsub/server.rb create mode 100644 packages/ably-pubsub-server/lib/ably/pubsub/server/version.rb create mode 100644 spec/unit/pubsub/device_spec.rb create mode 100644 spec/unit/pubsub/packaging_spec.rb create mode 100644 spec/unit/pubsub/server_spec.rb create mode 100644 spec/unit/util/deprecation_spec.rb diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index c10d05282..43d211beb 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -56,6 +56,25 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} flag-name: ruby-${{ matrix.ruby }}-${{ matrix.protocol }}-${{ matrix.type }} parallel: true + package: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 + with: + submodules: 'recursive' + persist-credentials: false + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 + with: + ruby-version: '3.3' + bundler-cache: true + # The packaging metadata for the Pub/Sub gems is otherwise only exercised at release time, + # where a mistake is expensive. spec/unit/pubsub/packaging_spec.rb covers what each gem + # ships and that the three stay on one version; this covers that they build at all. + - name: 'Check that every gem builds' + run: bundle exec rake build packages:build + finish: needs: check runs-on: ubuntu-latest diff --git a/.yardopts b/.yardopts new file mode 100644 index 000000000..e6f270841 --- /dev/null +++ b/.yardopts @@ -0,0 +1 @@ +lib/**/*.rb packages/*/lib/**/*.rb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eac0af682..f925fbc80 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,12 +10,43 @@ --- +## Repository layout + +This repository builds three gems, released together on the same version. They all install into the one `Ably` namespace, so what you require never tells you which gem shipped it: + +| Gem | Source | Required as | Role | +|-----|--------|-------------|------| +| `ably` | [`lib/`](./lib) | `ably` | The shared core, containing all of the implementation | +| `ably-pubsub-server` | [`packages/ably-pubsub-server/`](./packages/ably-pubsub-server) | `ably/pubsub/server` | The server-side factories | +| `ably-pubsub-device` | [`packages/ably-pubsub-device/`](./packages/ably-pubsub-device) | `ably/pubsub/device` | The device-side factory | + +Each side adds factories that return the core's clients unchanged, so that the gem a caller installs names the side their application runs on. They pin the core exactly, and requiring either makes the whole `Ably` namespace available. + +Two rules keep that arrangement working, and both are covered by [`spec/unit/pubsub/packaging_spec.rb`](./spec/unit/pubsub/packaging_spec.rb): + +- **Neither Pub/Sub gem may define `lib/ably/pubsub.rb`.** Both would have to ship it, and whichever came first on the load path would be the one required, hiding the other's. `Ably::PubSub` is opened by each side's own entry point instead. +- **Each Pub/Sub gem ships only its own subtree**, and the core gem ships none of `packages/`, so that no file is shipped by two gems. + +The [`Gemfile`](./Gemfile) points at both Pub/Sub gems by path, so `bundle exec rspec` exercises them against the core in this checkout rather than a published version of it. Their specs are in [`spec/unit/pubsub/`](./spec/unit/pubsub) and need no network. + +To build all three gems into `pkg/`: + +```shell +bundle exec rake build packages:build +``` + +A gemspec's file list is relative to the working directory, so each Pub/Sub gem is built from its own directory — which `rake packages:build` takes care of. + +--- + ## Release process This library uses [semantic versioning](http://semver.org/). For each release, the following needs to be done: +`ably`, `ably-pubsub-server` and `ably-pubsub-device` are released in lockstep on the same version, because the Pub/Sub gems pin the core exactly — a partial release is an unusable one. + 1. Create a branch for the release, named like `release/1.2.3` (where `1.2.3` is the new version number) -2. Update the version number in [version.rb](./lib/ably/version.rb) and commit the change. +2. Update the version number in all three of [`lib/ably/version.rb`](./lib/ably/version.rb), [`packages/ably-pubsub-server/lib/ably/pubsub/server/version.rb`](./packages/ably-pubsub-server/lib/ably/pubsub/server/version.rb) and [`packages/ably-pubsub-device/lib/ably/pubsub/device/version.rb`](./packages/ably-pubsub-device/lib/ably/pubsub/device/version.rb), and commit the change. The specs in [`spec/unit/pubsub/packaging_spec.rb`](./spec/unit/pubsub/packaging_spec.rb) fail if any of these drift apart, so run them before moving on. 3. Run [`github_changelog_generator`](https://github.com/github-changelog-generator/github-changelog-generator) to automate the update of the [CHANGELOG](./CHANGELOG.md). This may require some manual intervention, both in terms of how the command is run and how the change log file is modified. Your mileage may vary: - The command you will need to run will look something like this: `github_changelog_generator -u ably -p ably-ruby --since-tag v1.2.3 --output delta.md --token $GITHUB_TOKEN_WITH_REPO_ACCESS`. Generate token [here](https://github.com/settings/tokens/new?description=GitHub%20Changelog%20Generator%20token). - Using the command above, `--output delta.md` writes changes made after `--since-tag` to a new file @@ -26,6 +57,6 @@ This library uses [semantic versioning](http://semver.org/). For each release, t 6. Make a PR against `main`. Once the PR is approved, merge it into `main`. 7. Add a tag to the new `main` head commit and push to origin such as `git tag v1.0.3 && git push origin v1.0.3`. 8. Visit [https://github.com/ably/ably-ruby/tags](https://github.com/ably/ably-ruby/tags) and `Add release notes` for the release including links to the changelog entry. -9. Run `rake release` to publish the gem to [Rubygems](https://rubygems.org/gems/ably). +9. Run `rake release` to publish the core gem to [Rubygems](https://rubygems.org/gems/ably), then `rake packages:release` to publish [`ably-pubsub-server`](https://rubygems.org/gems/ably-pubsub-server) and [`ably-pubsub-device`](https://rubygems.org/gems/ably-pubsub-device). The Pub/Sub gems pin the core exactly, so publish them in that order — the core first, or their dependency cannot be resolved. 10. Release the [REST-only library `ably-ruby-rest`](https://github.com/ably/ably-ruby-rest#release-process). 11. Create the entry on the [Ably Changelog](https://changelog.ably.com/) (via [headwayapp](https://headwayapp.co/)). diff --git a/Gemfile b/Gemfile index 5d0856f56..31c62fc6e 100644 --- a/Gemfile +++ b/Gemfile @@ -2,3 +2,8 @@ source 'https://rubygems.org' # Specify your gem's dependencies in ably.gemspec gemspec + +# The Pub/Sub gems released alongside this one, so that the test suite exercises them against the +# core in this checkout rather than a published version of it. +gem 'ably-pubsub-server', path: 'packages/ably-pubsub-server' +gem 'ably-pubsub-device', path: 'packages/ably-pubsub-device' diff --git a/README.md b/README.md index c35fb62c8..70d381cc1 100644 --- a/README.md +++ b/README.md @@ -45,18 +45,24 @@ Ably aims to support a wide range of platforms and browsers. If you experience a ## Installation -To get started with your project, install the package: +Install the gem for the side your application runs on. Each pulls in `ably` and adds an entry point under `Ably::PubSub` naming that side: ```sh # Create a new Gemfile echo "source 'https://rubygems.org'" > Gemfile -echo "gem 'ably'" >> Gemfile +# Trusted server environments — publishing, token issuing, backend subscribers +echo "gem 'ably-pubsub-server'" >> Gemfile # provides Ably::PubSub::Server + +# End-user devices — desktop apps, CLIs, IoT and embedded clients +echo "gem 'ably-pubsub-device'" >> Gemfile # provides Ably::PubSub::Device # Install the gem bundle install ``` +Installing `ably` on its own also still works, and remains fully supported. It is the shared core both build on, and the clients they return are its clients unchanged. + > [!NOTE] Install [Ruby](https://www.ruby-lang.org/en/documentation/installation/) version 2.7 or greater. @@ -69,14 +75,15 @@ To use the Ably Realtime SDK in Ruby, the `EventMachine` reactor loop must be ru Wrap your code inside a `EventMachine.run` block: ```ruby -require 'ably' +require 'ably/pubsub/device' EventMachine.run do - client = Ably::Realtime.new(key: 'your-api-key') + client = Ably::PubSub::Device.create_client(key: 'your-api-key') client.connection.connect do puts "Connected with connection ID: #{client.connection.id}" end +end ``` --- @@ -87,7 +94,7 @@ The following code connects to Ably's realtime messaging service, subscribes to ```ruby # Initialize Ably Realtime client - realtime_client = Ably::Realtime.new(key: 'your-ably-api-key', client_id: 'me') + realtime_client = Ably::PubSub::Device.create_client(key: 'your-ably-api-key', client_id: 'me') # Wait for connection to be established realtime_client.connection.on(:connected) do @@ -108,6 +115,20 @@ end ``` +On a server, use `Ably::PubSub::Server.create_realtime_client` for the same client over a persistent connection, or `Ably::PubSub::Server.create_http_client` when publish, history, presence reads, stats and token issuing over HTTP are enough. The HTTP client is synchronous and needs no EventMachine reactor. + +### Migrating from the client constructors + +Constructing `Ably::Rest::Client` or `Ably::Realtime::Client` directly — including through the `Ably::Rest.new` and `Ably::Realtime.new` shorthands — still works and is not scheduled for removal, but it emits a deprecation warning naming the factory for your side: + +| Before | After | +|--------|-------| +| `Ably::Realtime.new(...)` on a device | `Ably::PubSub::Device.create_client(...)` | +| `Ably::Realtime.new(...)` on a server | `Ably::PubSub::Server.create_realtime_client(...)` | +| `Ably::Rest.new(...)` | `Ably::PubSub::Server.create_http_client(...)` | + +The factories take the same options as the constructors they replace and behave identically to them, so migrating is a change of entry point only. + --- ## Releases diff --git a/Rakefile b/Rakefile index af2d779a8..7df240d64 100644 --- a/Rakefile +++ b/Rakefile @@ -3,9 +3,37 @@ require 'bundler/setup' require 'bundler/gem_tasks' require 'json' +require_relative 'lib/ably/version' + require 'yard' YARD::Rake::YardocTask.new +# The ably-pubsub-server and ably-pubsub-device gems, released alongside this one — see +# CONTRIBUTING.md. `rake build` and `rake release`, from bundler/gem_tasks, cover the core gem. +PUBSUB_GEMS = %w(ably-pubsub-server ably-pubsub-device).freeze + +namespace :packages do + pkg_path = File.expand_path('pkg', __dir__) + + desc 'Build the Pub/Sub gems into pkg/' + task :build do + mkdir_p pkg_path + PUBSUB_GEMS.each do |gem_name| + # A gemspec's files are relative to the working directory, so each is built from its own + Dir.chdir("packages/#{gem_name}") do + sh "gem build #{gem_name}.gemspec --output #{pkg_path}/#{gem_name}-#{Ably::VERSION}.gem" + end + end + end + + desc 'Build and push the Pub/Sub gems to Rubygems' + task :release => :build do + PUBSUB_GEMS.each do |gem_name| + sh "gem push #{pkg_path}/#{gem_name}-#{Ably::VERSION}.gem" + end + end +end + begin require 'rspec/core/rake_task' diff --git a/ably.gemspec b/ably.gemspec index 765bdb702..23b077202 100644 --- a/ably.gemspec +++ b/ably.gemspec @@ -13,7 +13,9 @@ Gem::Specification.new do |spec| spec.homepage = 'http://github.com/ably/ably-ruby' spec.license = 'Apache-2.0' - spec.files = `git ls-files`.split($/) + # packages/ holds the ably-pubsub-server and ably-pubsub-device gems, each of which ships its + # own subtree and is released alongside this one — see CONTRIBUTING.md. + spec.files = `git ls-files`.split($/).reject { |file| file.start_with?('packages/') } spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ['lib'] diff --git a/lib/ably/realtime.rb b/lib/ably/realtime.rb index dc9766628..40532510f 100644 --- a/lib/ably/realtime.rb +++ b/lib/ably/realtime.rb @@ -41,6 +41,10 @@ module Ably module Realtime # Convenience method providing an alias to {Ably::Realtime::Client} constructor. # + # @deprecated Use {Ably::PubSub::Server.create_realtime_client}, from the `ably-pubsub-server` + # gem, or {Ably::PubSub::Device.create_client}, from the `ably-pubsub-device` gem, whichever + # names the side your application runs on. + # # @param (see Ably::Realtime::Client#initialize) # @option options (see Ably::Realtime::Client#initialize) # diff --git a/lib/ably/realtime/client.rb b/lib/ably/realtime/client.rb index b35aaa94b..3a1609992 100644 --- a/lib/ably/realtime/client.rb +++ b/lib/ably/realtime/client.rb @@ -1,11 +1,16 @@ require 'uri' require 'ably/realtime/channel/publisher' require 'ably/realtime/recovery_key_context' +require 'ably/util/deprecation' module Ably module Realtime # A client that extends the functionality of the {Ably::Realtime::Client} and provides additional realtime-specific features. # + # @deprecated Use {Ably::PubSub::Server.create_realtime_client}, from the `ably-pubsub-server` + # gem, or {Ably::PubSub::Device.create_client}, from the `ably-pubsub-device` gem, whichever + # names the side your application runs on. + # class Client include Ably::Modules::AsyncWrapper include Ably::Realtime::Channel::Publisher @@ -81,6 +86,10 @@ class Client # # @spec RSC1 # + # @deprecated Use {Ably::PubSub::Server.create_realtime_client} or + # {Ably::PubSub::Device.create_client}, which take the same options and return this same + # client. + # # @param (see {Ably::Rest::Client#initialize}) # @option options (see Ably::Rest::Client#initialize) An options {Hash} object. # @option options [Proc] :auth_callback when provided, the Proc will be called with the token params hash as the first argument, whenever a new token is required. @@ -118,6 +127,12 @@ def initialize(options) end end + Ably::Util::Deprecation.warn_constructor_deprecated( + 'Ably::Realtime::Client.new', + 'Ably::PubSub::Server.create_realtime_client, from the ably-pubsub-server gem, or ' \ + 'Ably::PubSub::Device.create_client, from the ably-pubsub-device gem' + ) + @transport_params = options.delete(:transport_params).to_h.each_with_object({}) do |(key, value), acc| acc[key.to_s] = value.to_s end diff --git a/lib/ably/rest.rb b/lib/ably/rest.rb index 28c9d130a..aa5e62b3d 100644 --- a/lib/ably/rest.rb +++ b/lib/ably/rest.rb @@ -21,6 +21,9 @@ module Ably module Rest # Convenience method providing an alias to {Ably::Rest::Client} constructor. # + # @deprecated Use {Ably::PubSub::Server.create_http_client}, from the `ably-pubsub-server` + # gem, which names the side your application runs on. + # # @param (see Ably::Rest::Client#initialize) # @option options (see Ably::Rest::Client#initialize) # diff --git a/lib/ably/rest/client.rb b/lib/ably/rest/client.rb index 3dbda2dfb..8dbf46ee2 100644 --- a/lib/ably/rest/client.rb +++ b/lib/ably/rest/client.rb @@ -7,11 +7,15 @@ require 'faraday/typhoeus' require 'ably/rest/middleware/exceptions' +require 'ably/util/deprecation' module Ably module Rest # A client that offers a simple stateless API to interact directly with Ably's REST API. # + # @deprecated Use {Ably::PubSub::Server.create_http_client}, from the `ably-pubsub-server` + # gem, which names the side your application runs on. + # class Client include Ably::Modules::Conversions include Ably::Modules::HttpHelpers @@ -129,6 +133,9 @@ class Client # # @spec RSC1 # + # @deprecated Use {Ably::PubSub::Server.create_http_client}, from the `ably-pubsub-server` + # gem, which takes the same options and returns this same client. + # # @param [Hash,String] options an options Hash or String used to configure the client and the authentication, or String with an API key or Token ID # @option options [Boolean] :tls (true) When false, TLS is disabled. Please note Basic Auth is disallowed without TLS as secrets cannot be transmitted over unsecured connections. # @option options [String] :key API key comprising the key name and key secret in a single string @@ -185,6 +192,14 @@ def initialize(options) end end + # A realtime client builds its REST client with itself as :realtime_client, and warns + # about its own constructor, so only direct use of this one is deprecated here. + unless options[:realtime_client] + Ably::Util::Deprecation.warn_constructor_deprecated( + 'Ably::Rest::Client.new', 'Ably::PubSub::Server.create_http_client, from the ably-pubsub-server gem' + ) + end + @agent = options.delete(:agent) || Ably::AGENT @realtime_client = options.delete(:realtime_client) @tls = options.delete_with_default(:tls, true) diff --git a/lib/ably/util/deprecation.rb b/lib/ably/util/deprecation.rb new file mode 100644 index 000000000..bc8517814 --- /dev/null +++ b/lib/ably/util/deprecation.rb @@ -0,0 +1,87 @@ +require 'set' + +module Ably + module Util + # Deprecation of the client constructors in favour of the Ably Pub/Sub gem factories. + # + # The `ably-pubsub-server` and `ably-pubsub-device` gems call the same constructors + # internally, so they suppress the warning for the duration of the call: the caller used + # the recommended entry point and has nothing to migrate. + # + # @api private + # + module Deprecation + # Where this SDK is loaded from. A warning is attributed to the first frame outside + # it, so that an entry point which delegates to a constructor — {Ably::Rest.new}, or + # {Ably::Realtime::Client} building its REST client — still points at the caller. + SDK_LIB_PATH = File.expand_path('../..', __dir__).freeze + + SUPPRESSED_KEY = :ably_constructor_deprecation_suppressed + + @warned = Set.new + @warned_mutex = Mutex.new + + class << self + # Silence the constructor deprecation warning for the duration of the block. + # + # This interface is only to be used by Ably-authored SDKs. + # + def suppress_constructor_deprecation + previously_suppressed = Thread.current[SUPPRESSED_KEY] + Thread.current[SUPPRESSED_KEY] = true + yield + ensure + Thread.current[SUPPRESSED_KEY] = previously_suppressed + end + + def suppressed? + !!Thread.current[SUPPRESSED_KEY] + end + + # Warn that using +constructor+ directly is deprecated. +replacement+ names the + # entry points to migrate to, so that the warning says exactly what to change. + # + # Warns once per call site, so that a client constructed per request or in a loop + # does not repeat the same advice for the rest of the process's life. + # + def warn_constructor_deprecated(constructor, replacement) + return if suppressed? + + location = calling_location + return unless first_warning_for?(constructor, location) + + Kernel.warn "#{location}: warning: #{constructor} is deprecated, in favour of the factory " \ + "naming the side your application runs on. Use #{replacement}. #{constructor} " \ + 'keeps working and is not scheduled for removal.' + end + + # Forget which call sites have already warned. + # + # Only for use by this SDK's own tests, which would otherwise see a warning from the + # first run of an example and none from a retry of it. + # + def reset_warnings! + @warned_mutex.synchronize { @warned.clear } + end + + private + + # +path:lineno+ of the code to tell about the deprecation, in the format + # `Kernel#warn`'s own `uplevel:` uses. + def calling_location + # 2 skips this method and #warn_constructor_deprecated, leaving the constructor first. + # At most a couple of SDK frames follow it — a factory, or a convenience constructor — + # so a handful of frames is enough to look at, and cheaper than the whole backtrace. + frames = caller_locations(2, 10) + # A frame with no absolute_path is evaluated code, so not this SDK's + frame = frames.find { |location| !location.absolute_path.to_s.start_with?(SDK_LIB_PATH) } || frames.first + "#{frame.path}:#{frame.lineno}" + end + + def first_warning_for?(constructor, location) + @warned_mutex.synchronize { !@warned.add?("#{constructor}@#{location}").nil? } + end + end + end + end +end diff --git a/packages/ably-pubsub-device/README.md b/packages/ably-pubsub-device/README.md new file mode 100644 index 000000000..2f33f61d3 --- /dev/null +++ b/packages/ably-pubsub-device/README.md @@ -0,0 +1,48 @@ +# Ably Pub/Sub Ruby SDK for devices + +The Ably Pub/Sub client for devices: applications running in end-user environments (desktop apps, CLIs, IoT and embedded clients) whose connections are identified by a `client_id` and counted on accounts with monthly-active-user billing. + +This gem adds `Ably::PubSub::Device` to [`ably`](https://rubygems.org/gems/ably), whose client it returns unchanged and whose whole `Ably` namespace it makes available. If your application runs in a trusted server environment instead, use [`ably-pubsub-server`](https://rubygems.org/gems/ably-pubsub-server). + +## Installation + +```sh +gem install ably-pubsub-device +``` + +Or add it to your `Gemfile`: + +```ruby +gem 'ably-pubsub-device' +``` + +## Usage + +The realtime client runs on [EventMachine](https://github.com/eventmachine/eventmachine), so it needs a running reactor: + +```ruby +require 'ably/pubsub/device' + +EventMachine.run do + client = Ably::PubSub::Device.create_client(key: 'your-ably-api-key', client_id: 'me') + channel = client.channels.get('test-channel') + + channel.subscribe do |message| + puts "Received message: #{message.data}" + end + + channel.publish 'test-event', 'hello world' +end +``` + +`create_client` takes the same options as `Ably::Realtime::Client.new`, and behaves identically to it. + +A device is usually best authenticated with a token rather than an API key, so that the key never leaves your server. Pass an `auth_url` or `auth_callback` in place of `key` — see [Ably's authentication docs](https://ably.com/docs/auth). + +## Migrating + +Constructing `Ably::Realtime::Client` directly still works and is not scheduled for removal, but it emits a deprecation warning, because the factory names the side your application runs on. Replace `Ably::Realtime.new(...)` or `Ably::Realtime::Client.new(...)` with `Ably::PubSub::Device.create_client(...)`. + +## Support, feedback, and troubleshooting + +For help or technical support, visit Ably's [support page](https://ably.com/support) or [GitHub Issues](https://github.com/ably/ably-ruby/issues). diff --git a/packages/ably-pubsub-device/ably-pubsub-device.gemspec b/packages/ably-pubsub-device/ably-pubsub-device.gemspec new file mode 100644 index 000000000..00222e1ea --- /dev/null +++ b/packages/ably-pubsub-device/ably-pubsub-device.gemspec @@ -0,0 +1,25 @@ +# coding: utf-8 +lib = File.expand_path('lib', __dir__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'ably/pubsub/device/version' + +Gem::Specification.new do |spec| + spec.name = 'ably-pubsub-device' + spec.version = Ably::PubSub::Device::VERSION + spec.authors = ['Ably'] + spec.email = ['support@ably.com'] + spec.description = %q{The Ably Pub/Sub Ruby client for devices: applications running in end-user environments whose connections are identified by a client_id and counted on accounts with monthly-active-user billing} + spec.summary = %q{Ably Pub/Sub client for devices} + spec.homepage = 'http://github.com/ably/ably-ruby' + spec.license = 'Apache-2.0' + spec.required_ruby_version = '>= 2.7' + + # See the equivalent comment in packages/ably-pubsub-server/ably-pubsub-server.gemspec for why + # this ships only its own subtree, and why there is no lib/ably/pubsub.rb. + spec.files = Dir.chdir(__dir__) { `git ls-files -z`.split("\x0") } + spec.require_paths = ['lib'] + + # Released in lockstep with the ably gem, which this pins exactly: this gem is a thin entry + # point onto the core's clients, so a mismatched pair is not a combination we ship. + spec.add_runtime_dependency 'ably', Ably::PubSub::Device::VERSION +end diff --git a/packages/ably-pubsub-device/lib/ably/pubsub/device.rb b/packages/ably-pubsub-device/lib/ably/pubsub/device.rb new file mode 100644 index 000000000..be6825f41 --- /dev/null +++ b/packages/ably-pubsub-device/lib/ably/pubsub/device.rb @@ -0,0 +1,48 @@ +require 'ably' + +require 'ably/pubsub/device/version' + +module Ably + module PubSub + # The Ably Pub/Sub client for devices. + # + # Devices are applications running in end-user environments — desktop apps, CLIs, IoT and + # embedded clients — whose connections are identified by a `client_id` and counted on accounts + # with monthly-active-user billing. This gem names that side, so that the client an application + # reaches for is the one whose gem matches where it runs. + # + # Use {create_client} to open a realtime connection with channels, presence and history. It + # returns the same client the `ably` gem does, with identical behaviour, so the whole `Ably` + # namespace is available once this gem is required. + # + # Ships in the `ably-pubsub-device` gem, which adds this module to the `Ably` namespace the + # `ably` gem provides. + # + module Device + class << self + # Creates a device Pub/Sub client: a realtime connection to Ably with channels, presence + # and history. + # + # Takes the same options as {Ably::Realtime::Client#initialize}, and behaves identically + # to it, so it requires a running EventMachine reactor. + # + # @param (see Ably::Realtime::Client#initialize) + # @option options (see Ably::Realtime::Client#initialize) + # + # @return [Ably::Realtime::Client] + # + # @example + # EventMachine.run do + # client = Ably::PubSub::Device.create_client(key: 'key.id:secret', client_id: 'me') + # client.channels.get('test-channel').subscribe { |message| puts message.data } + # end + # + def create_client(options) + Ably::Util::Deprecation.suppress_constructor_deprecation do + Ably::Realtime::Client.new(options) + end + end + end + end + end +end diff --git a/packages/ably-pubsub-device/lib/ably/pubsub/device/version.rb b/packages/ably-pubsub-device/lib/ably/pubsub/device/version.rb new file mode 100644 index 000000000..a99b9a3e5 --- /dev/null +++ b/packages/ably-pubsub-device/lib/ably/pubsub/device/version.rb @@ -0,0 +1,8 @@ +module Ably + module PubSub + module Device + # Released in lockstep with the `ably` gem, which this gem pins exactly. + VERSION = '1.2.8' + end + end +end diff --git a/packages/ably-pubsub-server/README.md b/packages/ably-pubsub-server/README.md new file mode 100644 index 000000000..8e98c7c1a --- /dev/null +++ b/packages/ably-pubsub-server/README.md @@ -0,0 +1,60 @@ +# Ably Pub/Sub Ruby SDK for servers + +The Ably Pub/Sub client for servers: trusted environments which typically authenticate with an API key, and whose connections are exempt from monthly-active-user counting. + +This gem adds `Ably::PubSub::Server` to [`ably`](https://rubygems.org/gems/ably), whose clients it returns unchanged and whose whole `Ably` namespace it makes available. If your application runs on an end-user device instead, use [`ably-pubsub-device`](https://rubygems.org/gems/ably-pubsub-device). + +## Installation + +```sh +gem install ably-pubsub-server +``` + +Or add it to your `Gemfile`: + +```ruby +gem 'ably-pubsub-server' +``` + +## Usage + +Use `create_http_client` when publish, history, presence reads, stats and token issuing over HTTP are enough: + +```ruby +require 'ably/pubsub/server' + +client = Ably::PubSub::Server.create_http_client(key: 'your-ably-api-key') +client.channels.get('test-channel').publish 'test-event', 'hello world' +``` + +Use `create_realtime_client` when the server needs a persistent connection — subscribing to channels, or entering presence. The realtime client runs on [EventMachine](https://github.com/eventmachine/eventmachine), so it needs a running reactor: + +```ruby +require 'ably/pubsub/server' + +EventMachine.run do + client = Ably::PubSub::Server.create_realtime_client(key: 'your-ably-api-key') + channel = client.channels.get('test-channel') + + channel.subscribe do |message| + puts "Received message: #{message.data}" + end + + channel.publish 'test-event', 'hello world' +end +``` + +Both factories take the same options as `Ably::Rest::Client.new` and `Ably::Realtime::Client.new`, and behave identically to them. + +## Migrating + +Constructing the clients directly still works and is not scheduled for removal, but it emits a deprecation warning, because the factories name the side your application runs on. Replace: + +| Before | After | +|--------|-------| +| `Ably::Rest.new(...)`, `Ably::Rest::Client.new(...)` | `Ably::PubSub::Server.create_http_client(...)` | +| `Ably::Realtime.new(...)`, `Ably::Realtime::Client.new(...)` | `Ably::PubSub::Server.create_realtime_client(...)` | + +## Support, feedback, and troubleshooting + +For help or technical support, visit Ably's [support page](https://ably.com/support) or [GitHub Issues](https://github.com/ably/ably-ruby/issues). diff --git a/packages/ably-pubsub-server/ably-pubsub-server.gemspec b/packages/ably-pubsub-server/ably-pubsub-server.gemspec new file mode 100644 index 000000000..83c0f1802 --- /dev/null +++ b/packages/ably-pubsub-server/ably-pubsub-server.gemspec @@ -0,0 +1,27 @@ +# coding: utf-8 +lib = File.expand_path('lib', __dir__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'ably/pubsub/server/version' + +Gem::Specification.new do |spec| + spec.name = 'ably-pubsub-server' + spec.version = Ably::PubSub::Server::VERSION + spec.authors = ['Ably'] + spec.email = ['support@ably.com'] + spec.description = %q{The Ably Pub/Sub Ruby client for servers: trusted environments which typically authenticate with an API key, and whose connections are exempt from monthly-active-user counting} + spec.summary = %q{Ably Pub/Sub client for servers} + spec.homepage = 'http://github.com/ably/ably-ruby' + spec.license = 'Apache-2.0' + spec.required_ruby_version = '>= 2.7' + + # This gem adds Ably::PubSub::Server to the Ably namespace the ably gem provides, so it ships + # that subtree and nothing else. In particular there is no lib/ably/pubsub.rb: it would be + # shipped by both this gem and ably-pubsub-device, and whichever came first on the load path + # would be the one required, hiding the other's. + spec.files = Dir.chdir(__dir__) { `git ls-files -z`.split("\x0") } + spec.require_paths = ['lib'] + + # Released in lockstep with the ably gem, which this pins exactly: this gem is a thin entry + # point onto the core's clients, so a mismatched pair is not a combination we ship. + spec.add_runtime_dependency 'ably', Ably::PubSub::Server::VERSION +end diff --git a/packages/ably-pubsub-server/lib/ably/pubsub/server.rb b/packages/ably-pubsub-server/lib/ably/pubsub/server.rb new file mode 100644 index 000000000..39c7b42b0 --- /dev/null +++ b/packages/ably-pubsub-server/lib/ably/pubsub/server.rb @@ -0,0 +1,68 @@ +require 'ably' + +require 'ably/pubsub/server/version' + +module Ably + module PubSub + # The Ably Pub/Sub client for servers. + # + # Servers are trusted environments which typically authenticate with an API key, and whose + # connections are exempt from monthly-active-user counting. This gem names that side, so that + # the client an application reaches for is the one whose gem matches where it runs. + # + # Use {create_http_client} for publish, history, presence reads, stats and token issuing over + # HTTP, and {create_realtime_client} when the server also needs to subscribe to channels or + # enter presence over a persistent connection. Both return the same clients the `ably` gem + # does, with identical behaviour, so the whole `Ably` namespace is available once this gem is + # required. + # + # Ships in the `ably-pubsub-server` gem, which adds this module to the `Ably` namespace the + # `ably` gem provides. + # + module Server + class << self + # Creates a server Pub/Sub client that operates entirely over HTTP. + # + # Takes the same options as {Ably::Rest::Client#initialize}, and behaves identically to it. + # + # @param (see Ably::Rest::Client#initialize) + # @option options (see Ably::Rest::Client#initialize) + # + # @return [Ably::Rest::Client] + # + # @example + # client = Ably::PubSub::Server.create_http_client(key: 'key.id:secret') + # client.channels.get('test-channel').publish 'test-event', 'hello world' + # + def create_http_client(options) + Ably::Util::Deprecation.suppress_constructor_deprecation do + Ably::Rest::Client.new(options) + end + end + + # Creates a server Pub/Sub client with a persistent realtime connection. + # + # Everything the HTTP client does, plus subscribing to channels and entering presence. + # Takes the same options as {Ably::Realtime::Client#initialize}, and behaves identically + # to it, so it requires a running EventMachine reactor. + # + # @param (see Ably::Realtime::Client#initialize) + # @option options (see Ably::Realtime::Client#initialize) + # + # @return [Ably::Realtime::Client] + # + # @example + # EventMachine.run do + # client = Ably::PubSub::Server.create_realtime_client(key: 'key.id:secret') + # client.channels.get('test-channel').publish 'test-event', 'hello world' + # end + # + def create_realtime_client(options) + Ably::Util::Deprecation.suppress_constructor_deprecation do + Ably::Realtime::Client.new(options) + end + end + end + end + end +end diff --git a/packages/ably-pubsub-server/lib/ably/pubsub/server/version.rb b/packages/ably-pubsub-server/lib/ably/pubsub/server/version.rb new file mode 100644 index 000000000..113c3318e --- /dev/null +++ b/packages/ably-pubsub-server/lib/ably/pubsub/server/version.rb @@ -0,0 +1,8 @@ +module Ably + module PubSub + module Server + # Released in lockstep with the `ably` gem, which this gem pins exactly. + VERSION = '1.2.8' + end + end +end diff --git a/spec/rspec_config.rb b/spec/rspec_config.rb index c299da4af..3a54850fb 100644 --- a/spec/rspec_config.rb +++ b/spec/rspec_config.rb @@ -29,6 +29,17 @@ WebMock.disable! end + # This suite constructs the deprecated clients throughout — they are what it tests — so left + # alone it would bury its own output in deprecation warnings. Examples tagged :deprecation are + # the ones asserting on the warning, so they opt out. + config.around(:example) do |example| + if example.metadata[:deprecation] + example.run + else + Ably::Util::Deprecation.suppress_constructor_deprecation { example.run } + end + end + config.before(:example, :webmock) do allow(TestApp).to receive(:instance).and_return(instance_double('TestApp', app_id: 'app_id', diff --git a/spec/unit/pubsub/device_spec.rb b/spec/unit/pubsub/device_spec.rb new file mode 100644 index 000000000..6cef16555 --- /dev/null +++ b/spec/unit/pubsub/device_spec.rb @@ -0,0 +1,52 @@ +# encoding: utf-8 +require 'spec_helper' +require 'ably/pubsub/device' + +describe Ably::PubSub::Device do + let(:api_key) { 'appid.keyuid:keysecret' } + let(:realtime_options) { { key: api_key, client_id: 'john', auto_connect: false } } + + context '#create_client' do + it 'returns the core realtime client' do + expect(subject.create_client(realtime_options)).to be_instance_of(Ably::Realtime::Client) + end + + it 'passes the options through' do + client = subject.create_client(realtime_options) + expect(client.client_id).to eql('john') + expect(client.auto_connect).to be_falsey + end + + it 'accepts an API key string, as the constructor does' do + expect(subject.create_client(api_key).auth.key_name).to eql('appid.keyuid') + end + + it 'still requires options' do + expect { subject.create_client(nil) }.to raise_error(ArgumentError) + end + end + + # :deprecation opts out of the suite-wide suppression in spec/rspec_config.rb, without which + # these would pass whether the factory suppressed the warning or not + context 'deprecation', :deprecation do + before { Ably::Util::Deprecation.reset_warnings! } + + it 'the factory does not warn, being the recommended entry point' do + expect(Kernel).to_not receive(:warn) + subject.create_client(realtime_options) + end + + it 'the constructor still warns after a factory call' do + subject.create_client(realtime_options) + expect(Kernel).to receive(:warn).once + Ably::Realtime::Client.new(realtime_options) + end + end + + context 'the core public API' do + it 'is available, this gem being a thin entry point onto it' do + expect(defined?(Ably::Realtime::Client)).to eql('constant') + expect(defined?(Ably::Models::TokenDetails)).to eql('constant') + end + end +end diff --git a/spec/unit/pubsub/packaging_spec.rb b/spec/unit/pubsub/packaging_spec.rb new file mode 100644 index 000000000..466dc7635 --- /dev/null +++ b/spec/unit/pubsub/packaging_spec.rb @@ -0,0 +1,71 @@ +# encoding: utf-8 + +# The Ably namespace is assembled at install time from three gems: ably ships the core, +# ably-pubsub-server ships Ably::PubSub::Server and ably-pubsub-device ships +# Ably::PubSub::Device. That only holds together if each ships its own subtree and the three +# stay on one version, so this covers both — neither would otherwise fail anywhere closer to +# the mistake than a release. +require 'spec_helper' +require 'ably/pubsub/server' +require 'ably/pubsub/device' + +describe 'Pub/Sub gem packaging' do + repo_root = File.expand_path('../../..', __dir__) + + pubsub_gems = { + 'ably-pubsub-server' => { side: 'server', version: Ably::PubSub::Server::VERSION }, + 'ably-pubsub-device' => { side: 'device', version: Ably::PubSub::Device::VERSION } + } + + gemspec_for = lambda do |gem_name| + path = if gem_name == 'ably' + File.join(repo_root, 'ably.gemspec') + else + File.join(repo_root, 'packages', gem_name, "#{gem_name}.gemspec") + end + Gem::Specification.load(path) || raise("could not load #{path}") + end + + it 'the core gem does not ship the Pub/Sub gems' do + expect(gemspec_for.call('ably').files.grep(%r{\Apackages/})).to be_empty + end + + # In Ruby the equivalent of a namespace package collision is a shared file: lib/ably/pubsub.rb + # would be shipped by both Pub/Sub gems, and whichever came first on the load path would be the + # one required, hiding the other's + it 'no gem defines lib/ably/pubsub.rb, which two of them would each have to ship' do + all_files = ['ably', *pubsub_gems.keys].flat_map { |gem_name| gemspec_for.call(gem_name).files } + expect(all_files).to_not include('lib/ably/pubsub.rb') + end + + pubsub_gems.each do |gem_name, gem_details| + context gem_name do + let(:gemspec) { gemspec_for.call(gem_name) } + + it 'ships only its own subtree' do + outside_own_subtree = gemspec.files.grep(/\.rb\z/).reject do |file| + file.start_with?("lib/ably/pubsub/#{gem_details[:side]}") + end + expect(outside_own_subtree).to be_empty + end + + it 'ships the entry point that gives it its name' do + expect(gemspec.files).to include("lib/ably/pubsub/#{gem_details[:side]}.rb") + end + + it 'carries the version of its VERSION constant' do + expect(gemspec.version.to_s).to eql(gem_details[:version]) + end + + it 'is released in lockstep with the core' do + expect(gem_details[:version]).to eql(Ably::VERSION) + end + + it 'pins the core exactly' do + core = gemspec.dependencies.find { |dependency| dependency.name == 'ably' } + expect(core).to_not be_nil + expect(core.requirement.to_s).to eql("= #{Ably::VERSION}") + end + end + end +end diff --git a/spec/unit/pubsub/server_spec.rb b/spec/unit/pubsub/server_spec.rb new file mode 100644 index 000000000..4a1187d65 --- /dev/null +++ b/spec/unit/pubsub/server_spec.rb @@ -0,0 +1,69 @@ +# encoding: utf-8 +require 'spec_helper' +require 'ably/pubsub/server' + +describe Ably::PubSub::Server do + let(:api_key) { 'appid.keyuid:keysecret' } + let(:rest_options) { { key: api_key, client_id: 'john' } } + let(:realtime_options) { { key: api_key, client_id: 'john', auto_connect: false } } + + context '#create_http_client' do + it 'returns the core REST client' do + expect(subject.create_http_client(rest_options)).to be_instance_of(Ably::Rest::Client) + end + + it 'passes the options through' do + client = subject.create_http_client(rest_options.merge(tls: false)) + expect(client.client_id).to eql('john') + expect(client.use_tls?).to be_falsey + end + + it 'accepts an API key string, as the constructor does' do + expect(subject.create_http_client(api_key).auth.key_name).to eql('appid.keyuid') + end + + it 'still requires options' do + expect { subject.create_http_client(nil) }.to raise_error(ArgumentError) + end + end + + context '#create_realtime_client' do + it 'returns the core realtime client' do + expect(subject.create_realtime_client(realtime_options)).to be_instance_of(Ably::Realtime::Client) + end + + it 'passes the options through' do + expect(subject.create_realtime_client(realtime_options).client_id).to eql('john') + end + + it 'accepts an API key string, as the constructor does' do + expect(subject.create_realtime_client(api_key).auth.key_name).to eql('appid.keyuid') + end + end + + # :deprecation opts out of the suite-wide suppression in spec/rspec_config.rb, without which + # these would pass whether the factories suppressed the warning or not + context 'deprecation', :deprecation do + before { Ably::Util::Deprecation.reset_warnings! } + + it 'the factories do not warn, being the recommended entry point' do + expect(Kernel).to_not receive(:warn) + subject.create_http_client(rest_options) + subject.create_realtime_client(realtime_options) + end + + it 'the constructors still warn after a factory call' do + subject.create_http_client(rest_options) + expect(Kernel).to receive(:warn).once + Ably::Rest::Client.new(rest_options) + end + end + + context 'the core public API' do + it 'is available, this gem being a thin entry point onto it' do + expect(defined?(Ably::Rest::Client)).to eql('constant') + expect(defined?(Ably::Realtime::Client)).to eql('constant') + expect(defined?(Ably::Models::TokenDetails)).to eql('constant') + end + end +end diff --git a/spec/unit/util/deprecation_spec.rb b/spec/unit/util/deprecation_spec.rb new file mode 100644 index 000000000..238dae162 --- /dev/null +++ b/spec/unit/util/deprecation_spec.rb @@ -0,0 +1,103 @@ +# encoding: utf-8 +require 'spec_helper' + +# :deprecation opts out of the suite-wide suppression in spec/rspec_config.rb +describe Ably::Util::Deprecation, :deprecation do + let(:rest_options) { { key: 'appid.keyuid:keysecret' } } + let(:realtime_options) { { key: 'appid.keyuid:keysecret', auto_connect: false } } + + before do + # A warning is emitted once per call site, so a retried example would see none + Ably::Util::Deprecation.reset_warnings! + end + + context 'Ably::Rest::Client.new' do + it 'names the server gem factory' do + expect(Kernel).to receive(:warn).with( + /Ably::Rest::Client\.new is deprecated.+Ably::PubSub::Server\.create_http_client, from the ably-pubsub-server gem/ + ) + Ably::Rest::Client.new(rest_options) + end + + it 'says that the constructor keeps working' do + expect(Kernel).to receive(:warn).with(/keeps working and is not scheduled for removal/) + Ably::Rest::Client.new(rest_options) + end + + it 'attributes the warning to the calling code' do + expect(Kernel).to receive(:warn).with(a_string_starting_with("#{__FILE__}:")) + Ably::Rest::Client.new(rest_options) + end + + it 'warns once for a call site reached more than once' do + expect(Kernel).to receive(:warn).once + 2.times { Ably::Rest::Client.new(rest_options) } + end + + it 'warns for each distinct call site' do + expect(Kernel).to receive(:warn).twice + Ably::Rest::Client.new(rest_options) + Ably::Rest::Client.new(rest_options) + end + end + + context 'Ably::Realtime::Client.new' do + it 'names both factories, either of which returns this client' do + expect(Kernel).to receive(:warn).with( + /Ably::Realtime::Client\.new is deprecated.+Ably::PubSub::Server\.create_realtime_client.+Ably::PubSub::Device\.create_client/ + ) + Ably::Realtime::Client.new(realtime_options) + end + + # The realtime client builds a REST client, which must not warn about itself as well + it 'warns once' do + expect(Kernel).to receive(:warn).once + Ably::Realtime::Client.new(realtime_options) + end + end + + context 'the convenience constructors' do + it 'attributes Ably::Rest.new to the calling code, not to itself' do + expect(Kernel).to receive(:warn).with(a_string_starting_with("#{__FILE__}:")) + Ably::Rest.new(rest_options) + end + + it 'attributes Ably::Realtime.new to the calling code, not to itself' do + expect(Kernel).to receive(:warn).with(a_string_starting_with("#{__FILE__}:")) + Ably::Realtime.new(realtime_options) + end + end + + context '#suppress_constructor_deprecation' do + it 'silences the warning within the block' do + expect(Kernel).to_not receive(:warn) + Ably::Util::Deprecation.suppress_constructor_deprecation { Ably::Rest::Client.new(rest_options) } + end + + it 'restores the warning after the block' do + Ably::Util::Deprecation.suppress_constructor_deprecation { Ably::Rest::Client.new(rest_options) } + expect(Kernel).to receive(:warn).once + Ably::Rest::Client.new(rest_options) + end + + it 'restores the warning after the block raises' do + expect do + Ably::Util::Deprecation.suppress_constructor_deprecation { raise 'boom' } + end.to raise_error('boom') + expect(Kernel).to receive(:warn).once + Ably::Rest::Client.new(rest_options) + end + + it 'leaves an enclosing suppression in place' do + expect(Kernel).to_not receive(:warn) + Ably::Util::Deprecation.suppress_constructor_deprecation do + Ably::Util::Deprecation.suppress_constructor_deprecation { } + Ably::Rest::Client.new(rest_options) + end + end + + it 'returns the value of the block' do + expect(Ably::Util::Deprecation.suppress_constructor_deprecation { :result }).to eql(:result) + end + end +end