diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index c10d0528..43d211be 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 00000000..e6f27084 --- /dev/null +++ b/.yardopts @@ -0,0 +1 @@ +lib/**/*.rb packages/*/lib/**/*.rb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eac0af68..f925fbc8 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 5d0856f5..31c62fc6 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 c35fb62c..70d381cc 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 af2d779a..7df240d6 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 765bdb70..23b07720 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 dc976662..40532510 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 b35aaa94..3a160999 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 28c9d130..aa5e62b3 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 3dbda2df..8dbf46ee 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 00000000..bc851781 --- /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 00000000..2f33f61d --- /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 00000000..00222e1e --- /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 00000000..be6825f4 --- /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 00000000..a99b9a3e --- /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 00000000..8e98c7c1 --- /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 00000000..83c0f180 --- /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 00000000..39c7b42b --- /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 00000000..113c3318 --- /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 c299da4a..3a54850f 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 00000000..6cef1655 --- /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 00000000..466dc763 --- /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 00000000..4a1187d6 --- /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 00000000..238dae16 --- /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