├── .rspec ├── COPYRIGHT ├── MAINTAINERS.md ├── .gitignore ├── images └── rubySDK-github.png ├── lib ├── ably │ ├── agent.rb │ ├── version.rb │ ├── modules │ │ ├── message_pack.rb │ │ ├── event_machine_helpers.rb │ │ ├── statesman_monkey_patch.rb │ │ ├── ably.rb │ │ ├── http_helpers.rb │ │ ├── safe_yield.rb │ │ ├── state_machine.rb │ │ ├── async_wrapper.rb │ │ ├── model_common.rb │ │ ├── message_emitter.rb │ │ ├── safe_deferrable.rb │ │ └── channels_collection.rb │ ├── models │ │ ├── nil_logger.rb │ │ ├── message_encoders │ │ │ ├── utf8.rb │ │ │ ├── json.rb │ │ │ ├── base64.rb │ │ │ └── cipher.rb │ │ ├── delta_extras.rb │ │ ├── auth_details.rb │ │ ├── channel_occupancy.rb │ │ ├── channel_status.rb │ │ ├── channel_details.rb │ │ ├── connection_state_change.rb │ │ ├── channel_state_change.rb │ │ ├── device_push_details.rb │ │ ├── error_info.rb │ │ ├── channel_metrics.rb │ │ ├── http_paginated_response.rb │ │ ├── device_details.rb │ │ ├── stats_types.rb │ │ ├── channel_options.rb │ │ └── push_channel_subscription.rb │ ├── realtime │ │ ├── push.rb │ │ ├── models │ │ │ └── nil_channel.rb │ │ ├── recovery_key_context.rb │ │ ├── channel │ │ │ ├── channel_properties.rb │ │ │ ├── push_channel.rb │ │ │ └── publisher.rb │ │ ├── presence │ │ │ ├── presence_state_machine.rb │ │ │ └── presence_manager.rb │ │ ├── push │ │ │ ├── admin.rb │ │ │ └── device_registrations.rb │ │ ├── channels.rb │ │ └── client │ │ │ └── outgoing_message_dispatcher.rb │ ├── rest │ │ ├── push.rb │ │ ├── middleware │ │ │ ├── external_exceptions.rb │ │ │ ├── fail_if_unsupported_mime_type.rb │ │ │ ├── parse_json.rb │ │ │ ├── encoder.rb │ │ │ ├── logger.rb │ │ │ ├── parse_message_pack.rb │ │ │ └── exceptions.rb │ │ ├── channels.rb │ │ ├── channel │ │ │ └── push_channel.rb │ │ └── push │ │ │ └── admin.rb │ ├── util │ │ ├── ably_extensions.rb │ │ ├── pub_sub.rb │ │ └── safe_deferrable.rb │ ├── rest.rb │ └── realtime.rb └── ably.rb ├── Gemfile ├── .gitmodules ├── .rspec_parallel ├── .github └── workflows │ ├── features.yml │ ├── docs.yml │ └── check.yml ├── spec ├── unit │ ├── rest │ │ ├── rest_spec.rb │ │ └── push_channel_spec.rb │ ├── realtime │ │ ├── safe_deferrable_spec.rb │ │ ├── realtime_spec.rb │ │ ├── websocket_transport_spec.rb │ │ ├── incoming_message_dispatcher_spec.rb │ │ ├── push_channel_spec.rb │ │ ├── connection_spec.rb │ │ ├── recovery_key_context_spec.rb │ │ └── client_spec.rb │ ├── models │ │ ├── delta_extras_spec.rb │ │ ├── channel_occupancy_spec.rb │ │ ├── channel_metrics_spec.rb │ │ ├── channel_details_spec.rb │ │ ├── auth_details_spec.rb │ │ ├── channel_status_spec.rb │ │ ├── message_encoders │ │ │ └── utf8_spec.rb │ │ ├── channel_state_change_spec.rb │ │ ├── connection_state_change_spec.rb │ │ ├── connection_details_spec.rb │ │ ├── error_info_spec.rb │ │ └── push_channel_subscription_spec.rb │ ├── modules │ │ └── conversions_spec.rb │ ├── auth_spec.rb │ └── util │ │ └── pub_sub_spec.rb ├── support │ ├── random_helper.rb │ ├── serialization_helper.rb │ ├── rest_testapp_before_retry.rb │ ├── protocol_helper.rb │ ├── test_logger_helper.rb │ ├── private_api_formatter.rb │ ├── api_helper.rb │ ├── event_emitter_helper.rb │ ├── debug_failure_helper.rb │ └── test_app.rb ├── acceptance │ ├── rest │ │ ├── time_spec.rb │ │ └── channels_spec.rb │ └── realtime │ │ ├── stats_spec.rb │ │ ├── time_spec.rb │ │ └── presence_history_spec.rb ├── run_parallel_tests ├── shared │ ├── protocol_msgbus_behaviour.rb │ ├── safe_deferrable_behaviour.rb │ └── model_behaviour.rb ├── spec_helper.rb ├── lib │ └── unit │ │ └── models │ │ └── channel_options_spec.rb └── rspec_config.rb ├── .editorconfig ├── INTRO.md ├── UPDATING.md ├── Rakefile ├── ably.gemspec ├── CONTRIBUTING.md └── .ably └── capabilities.yaml /.rspec: -------------------------------------------------------------------------------- 1 | --color --format documentation 2 | -------------------------------------------------------------------------------- /COPYRIGHT: -------------------------------------------------------------------------------- 1 | Copyright 2015-2022 Ably Real-time Ltd (ably.com) 2 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | This repository is owned by the Ably SDK team. 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | .yardoc 3 | doc/* 4 | pkg 5 | .bundle 6 | bin 7 | coverage 8 | -------------------------------------------------------------------------------- /images/rubySDK-github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably/ably-ruby/HEAD/images/rubySDK-github.png -------------------------------------------------------------------------------- /lib/ably/agent.rb: -------------------------------------------------------------------------------- 1 | module Ably 2 | AGENT = "ably-ruby/#{Ably::VERSION} ruby/#{RUBY_VERSION}" 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in ably.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/submodules/ably-common"] 2 | path = lib/submodules/ably-common 3 | url = https://github.com/ably/ably-common.git 4 | -------------------------------------------------------------------------------- /.rspec_parallel: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --format RspecJunitFormatter 3 | --out junit/<%= ENV['TEST_ENV_NUMBER'] %>_<%= ENV['TEST_TYPE'] %>_<%= ENV['PROTOCOL'] %>_ruby-<%= ENV['RUBY_VERSION'] %>.junit 4 | -------------------------------------------------------------------------------- /lib/ably/version.rb: -------------------------------------------------------------------------------- 1 | module Ably 2 | VERSION = '1.2.8' 3 | # The level of compatibility with the Ably service that this SDK supports. 4 | # Also referred to as the 'wire protocol version'. 5 | # spec : CSV2 6 | PROTOCOL_VERSION = '2' 7 | end 8 | -------------------------------------------------------------------------------- /.github/workflows/features.yml: -------------------------------------------------------------------------------- 1 | name: Features 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | uses: ably/features/.github/workflows/sdk-features.yml@main 12 | with: 13 | repository-name: ably-ruby 14 | secrets: inherit 15 | -------------------------------------------------------------------------------- /spec/unit/rest/rest_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe Ably::Rest do 5 | let(:options) { { key: 'app.key:secret' } } 6 | 7 | specify 'constructor returns an Ably::Rest::Client' do 8 | expect(Ably::Rest.new(options)).to be_instance_of(Ably::Rest::Client) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://EditorConfig.org 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent-size = 2 11 | charset = utf-8 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /spec/support/random_helper.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | 3 | module RandomHelper 4 | def random_str(length = 16) 5 | SecureRandom.hex(length).encode(Encoding::UTF_8) 6 | end 7 | 8 | def random_int_str(size = 1_000_000_000) 9 | SecureRandom.random_number(size).to_s.encode(Encoding::UTF_8) 10 | end 11 | 12 | RSpec.configure do |config| 13 | config.include self 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/ably.rb: -------------------------------------------------------------------------------- 1 | require 'addressable/uri' 2 | 3 | require 'ably/version' 4 | require 'ably/agent' 5 | 6 | %w(modules util).each do |namespace| 7 | Dir.glob(File.expand_path("ably/#{namespace}/*.rb", File.dirname(__FILE__))).sort.each do |file| 8 | require file 9 | end 10 | end 11 | 12 | require 'ably/auth' 13 | require 'ably/exceptions' 14 | require 'ably/logger' 15 | require 'ably/realtime' 16 | require 'ably/rest' 17 | -------------------------------------------------------------------------------- /spec/unit/realtime/safe_deferrable_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | require 'shared/safe_deferrable_behaviour' 4 | require 'ably/realtime' 5 | 6 | [Ably::Models::ProtocolMessage, Ably::Models::Message, Ably::Models::PresenceMessage].each do |model_klass| 7 | describe model_klass do 8 | subject { model_klass.new(action: 1) } 9 | 10 | it_behaves_like 'a safe Deferrable' 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/unit/realtime/realtime_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ably::Realtime do 4 | let(:options) { { key: 'app.key:secret', auto_connect: false } } 5 | 6 | specify 'constructor returns an Ably::Realtime::Client' do 7 | expect(Ably::Realtime.new(options)).to be_instance_of(Ably::Realtime::Client) 8 | end 9 | 10 | after(:all) do 11 | sleep 1 # let realtime library shut down any open clients 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/unit/models/delta_extras_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe Ably::Models::DeltaExtras do 5 | subject { described_class.new({ format: 'vcdiff', from: '1234-4567-8910-1001-1111'}) } 6 | 7 | it 'should have `from` attribute' do 8 | expect(subject.from).to eq('1234-4567-8910-1001-1111') 9 | end 10 | 11 | it 'should have `format` attribute' do 12 | expect(subject.format).to eq('vcdiff') 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/ably/modules/message_pack.rb: -------------------------------------------------------------------------------- 1 | require 'msgpack' 2 | 3 | module Ably::Modules 4 | # MessagePack module adds #to_msgpack to the class on the assumption that the class 5 | # supports the method #as_json 6 | # 7 | module MessagePack 8 | # Generate a packed MsgPack version of this object based on the JSON representation. 9 | # Keys thus use mixedCase syntax as expected by the Realtime API 10 | def to_msgpack(pk = nil) 11 | as_json.to_msgpack(pk) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/support/serialization_helper.rb: -------------------------------------------------------------------------------- 1 | module SerializationHelper 2 | def serialize_body(object, protocol) 3 | if protocol == :msgpack 4 | MessagePack.pack(object) 5 | else 6 | JSON.dump(object) 7 | end 8 | end 9 | 10 | def deserialize_body(object, protocol) 11 | if protocol == :msgpack 12 | MessagePack.unpack(object) 13 | else 14 | JSON.parse(object) 15 | end 16 | end 17 | 18 | RSpec.configure do |config| 19 | config.include self 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/ably/models/nil_logger.rb: -------------------------------------------------------------------------------- 1 | module Ably::Models 2 | # When Log Level set to none, this NilLogger is used to silence all logging 3 | # NilLogger provides a Ruby Logger compatible interface 4 | class NilLogger 5 | def null_method(*args) 6 | end 7 | 8 | def level 9 | :none 10 | end 11 | 12 | def level=(value) 13 | level 14 | end 15 | 16 | [:fatal, :error, :warn, :info, :debug].each do |method| 17 | alias_method method, :null_method 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/support/rest_testapp_before_retry.rb: -------------------------------------------------------------------------------- 1 | # If a test fails and RSPEC_RETRY is set to true, create a new 2 | # application before retrying the RSpec test again 3 | # 4 | RSpec.configure do |config| 5 | config.around(:example) do |example| 6 | example.run 7 | 8 | next if example.metadata[:webmock] # new app is not needed for a mocked test 9 | 10 | if example.exception && ENV['RSPEC_RETRY'] 11 | reload_test_app 12 | puts "** Test app reloaded before next retry **" 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/ably/realtime/push.rb: -------------------------------------------------------------------------------- 1 | require 'ably/realtime/push/admin' 2 | 3 | module Ably 4 | module Realtime 5 | # Class providing push notification functionality 6 | class Push 7 | # @private 8 | attr_reader :client 9 | 10 | def initialize(client) 11 | @client = client 12 | end 13 | 14 | # A {Ably::Realtime::Push::Admin} object. 15 | # 16 | # @spec RSH1 17 | # 18 | # @return [Ably::Realtime::Push::Admin] 19 | # 20 | def admin 21 | @admin ||= Admin.new(self) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/ably/rest/push.rb: -------------------------------------------------------------------------------- 1 | require 'ably/rest/push/admin' 2 | 3 | module Ably 4 | module Rest 5 | # Class providing push notification functionality 6 | class Push 7 | include Ably::Modules::Conversions 8 | 9 | # @private 10 | attr_reader :client 11 | 12 | def initialize(client) 13 | @client = client 14 | end 15 | 16 | # Admin features for push notifications like managing devices and channel subscriptions 17 | # 18 | # @return [Ably::Rest::Push::Admin] 19 | # 20 | def admin 21 | @admin ||= Admin.new(self) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/ably/util/ably_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ably::Util 4 | module AblyExtensions 5 | refine Object do 6 | def nil_or_empty? 7 | self.nil? || self.empty? 8 | end 9 | end 10 | 11 | refine Hash do 12 | def fetch_with_default(key, default) 13 | value = self.fetch(key, default) 14 | if value.nil? 15 | return default 16 | end 17 | return value 18 | end 19 | 20 | def delete_with_default(key, default) 21 | value = self.delete(key) 22 | if value.nil? 23 | return default 24 | end 25 | return value 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/unit/models/channel_occupancy_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'shared/model_behaviour' 3 | 4 | describe Ably::Models::ChannelOccupancy do 5 | subject { Ably::Models::ChannelOccupancy({ metrics: { connections: 1, presence_connections: 2, presence_members: 2, presence_subscribers: 5, publishers: 7, subscribers: 9 } }) } 6 | 7 | describe '#metrics' do 8 | it 'should return attributes' do 9 | expect(subject.metrics.connections).to eq(1) 10 | expect(subject.metrics.presence_connections).to eq(2) 11 | expect(subject.metrics.presence_members).to eq(2) 12 | expect(subject.metrics.presence_subscribers).to eq(5) 13 | expect(subject.metrics.publishers).to eq(7) 14 | expect(subject.metrics.subscribers).to eq(9) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/ably/rest/middleware/external_exceptions.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | 3 | module Ably 4 | module Rest 5 | module Middleware 6 | # HTTP exceptions raised due to a status code error on a 3rd party site 7 | # Used by auth calls 8 | class ExternalExceptions < Faraday::Middleware 9 | def on_complete(env) 10 | if env.status >= 400 11 | error_status_code = env.status 12 | message = "Error #{error_status_code}: #{(env.body || '')[0...200]}" 13 | 14 | if error_status_code >= 500 15 | raise Ably::Exceptions::ServerError, message 16 | else 17 | raise Ably::Exceptions::InvalidRequest, message 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/ably/rest/middleware/fail_if_unsupported_mime_type.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | require 'json' 3 | 4 | module Ably 5 | module Rest 6 | module Middleware 7 | class FailIfUnsupportedMimeType < Faraday::Middleware 8 | def on_complete(env) 9 | unless env.response_headers['Ably-Middleware-Parsed'] == true 10 | # Ignore empty body with success status code for no body response 11 | return if env.body.to_s.empty? && env.status == 204 12 | 13 | unless (500..599).include?(env.status) 14 | raise Ably::Exceptions::InvalidResponseBody, 15 | "Content Type #{env.response_headers['Content-Type']} is not supported by this client library" 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/ably/models/message_encoders/utf8.rb: -------------------------------------------------------------------------------- 1 | require 'ably/models/message_encoders/base' 2 | 3 | module Ably::Models::MessageEncoders 4 | # Utf8 Encoder and Decoder 5 | # Uses encoding identifier 'utf-8' and encodes all JSON objects as UTF-8, and sets the encoding when decoding 6 | # 7 | class Utf8 < Base 8 | ENCODING_ID = 'utf-8' 9 | 10 | def encode(message, channel_options) 11 | # no encoding of UTF-8 required 12 | end 13 | 14 | def decode(message, channel_options) 15 | if is_utf8_encoded?(message) 16 | message[:data] = message[:data].force_encoding(Encoding::UTF_8) 17 | strip_current_encoding_part message 18 | end 19 | end 20 | 21 | private 22 | def is_utf8_encoded?(message) 23 | current_encoding_part(message).to_s.match(/^#{ENCODING_ID}$/i) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/ably/rest/middleware/parse_json.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | require 'json' 3 | 4 | module Ably 5 | module Rest 6 | module Middleware 7 | class ParseJson < Faraday::Middleware 8 | def on_complete(env) 9 | if env.response_headers['Content-Type'] == 'application/json' 10 | env.body = parse(env.body) unless env.response_headers['Ably-Middleware-Parsed'] == true 11 | env.response_headers['Ably-Middleware-Parsed'] = true 12 | end 13 | end 14 | 15 | def parse(body) 16 | if body.length > 0 17 | JSON.parse(body) 18 | else 19 | body 20 | end 21 | rescue JSON::ParserError => e 22 | raise Ably::Exceptions::InvalidResponseBody, "Expected JSON response: #{e.message}" 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/ably/models/delta_extras.rb: -------------------------------------------------------------------------------- 1 | module Ably::Models 2 | # Contains any arbitrary key-value pairs, which may also contain other primitive JSON types, JSON-encodable objects, 3 | # or JSON-encodable arrays from delta compression. 4 | # 5 | class DeltaExtras 6 | include Ably::Modules::ModelCommon 7 | 8 | # The ID of the message the delta was generated from. 9 | # 10 | # @return [String, nil] 11 | # 12 | attr_reader :from 13 | 14 | # The delta compression format. Only vcdiff is supported. 15 | # 16 | # @return [String, nil] 17 | # 18 | attr_reader :format 19 | 20 | def initialize(attributes = {}) 21 | @from, @format = IdiomaticRubyWrapper((attributes || {}), stop_at: [:from, :format]).attributes.values_at(:from, :format) 22 | end 23 | 24 | def to_json(*args) 25 | as_json(args).to_json 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/acceptance/rest/time_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ably::Rest::Client, '#time' do 4 | vary_by_protocol do 5 | let(:client) do 6 | Ably::Rest::Client.new(key: api_key, environment: environment, protocol: protocol) 7 | end 8 | 9 | describe 'fetching the service time' do 10 | it 'should return the service time as a Time object' do 11 | expect(client.time).to be_within(2).of(Time.now) 12 | end 13 | 14 | context 'with reconfigured HTTP timeout' do 15 | let(:client) do 16 | Ably::Rest::Client.new(http_request_timeout: 0.0001, key: api_key, environment: environment, protocol: protocol, log_retries_as_info: true) 17 | end 18 | 19 | it 'should raise a timeout exception' do 20 | expect { client.time }.to raise_error Ably::Exceptions::ConnectionTimeout 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/ably/modules/event_machine_helpers.rb: -------------------------------------------------------------------------------- 1 | require 'eventmachine' 2 | 3 | module Ably::Modules 4 | # EventMachineHelpers module provides common private methods to classes simplifying interaction with EventMachine 5 | module EventMachineHelpers 6 | private 7 | 8 | # This method allows looped blocks to be run at the next EventMachine tick 9 | # @example 10 | # x = 0 11 | # less_than_3 = -> { x < 3 } 12 | # non_blocking_loop_while(less_than_3) do 13 | # x += 1 14 | # end 15 | def non_blocking_loop_while(lambda_condition, &execution_block) 16 | if lambda_condition.call 17 | EventMachine.next_tick do 18 | if lambda_condition.call # ensure condition is still met following #next_tick 19 | yield 20 | non_blocking_loop_while(lambda_condition, &execution_block) 21 | end 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/ably/realtime/models/nil_channel.rb: -------------------------------------------------------------------------------- 1 | module Ably::Realtime::Models 2 | # Nil object for Channels, this object is only used within the internal API of this client library 3 | # @api private 4 | class NilChannel 5 | include Ably::Modules::EventEmitter 6 | extend Ably::Modules::Enum 7 | STATE = ruby_enum('STATE', Ably::Realtime::Channel::STATE) 8 | include Ably::Modules::StateEmitter 9 | include Ably::Modules::UsesStateMachine 10 | 11 | attr_reader :state_machine 12 | 13 | def initialize 14 | @state_machine = Ably::Realtime::Channel::ChannelStateMachine.new(self) 15 | @state = STATE(state_machine.current_state) 16 | end 17 | 18 | def name 19 | 'Nil channel' 20 | end 21 | 22 | def __incoming_msgbus__ 23 | @__incoming_msgbus__ ||= Ably::Util::PubSub.new 24 | end 25 | 26 | def logger 27 | @logger ||= Ably::Models::NilLogger.new 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/support/protocol_helper.rb: -------------------------------------------------------------------------------- 1 | module RSpec 2 | module ProtocolHelper 3 | SUPPORTED_PROTOCOLS = { 4 | json: 'JSON', 5 | msgpack: 'MsgPack' 6 | } 7 | 8 | PROTOCOLS = if ENV['PROTOCOL'] 9 | protocol = ENV['PROTOCOL'].downcase.to_sym 10 | { protocol => SUPPORTED_PROTOCOLS[protocol] } 11 | else 12 | SUPPORTED_PROTOCOLS 13 | end 14 | 15 | def vary_by_protocol(&block) 16 | RSpec::ProtocolHelper::PROTOCOLS.each do |protocol, description| 17 | context("using #{description} protocol", protocol: protocol, &block) 18 | end 19 | end 20 | end 21 | end 22 | 23 | RSpec.configure do |config| 24 | config.extend RSpec::ProtocolHelper 25 | 26 | config.before(:context, protocol: :json) do |context| 27 | context.class.let(:protocol) { :json } 28 | end 29 | 30 | config.before(:context, protocol: :msgpack) do |context| 31 | context.class.let(:protocol) { :msgpack } 32 | end 33 | end 34 | 35 | 36 | -------------------------------------------------------------------------------- /INTRO.md: -------------------------------------------------------------------------------- 1 | # Ably `Ruby` Client Library SDK API Reference 2 | 3 | The `Ruby` Client Library SDK supports a realtime and a REST interface. 4 | 5 | The realtime interface enables a client to maintain a persistent connection to Ably and publish, subscribe and be present on channels. 6 | The REST interface is stateless and typically implemented server-side. It is used to make requests such as retrieving statistics, 7 | token authentication and publishing to a channel. 8 | 9 | **Note**: The `Ruby` Client Library SDK implements the realtime and REST interfaces as two separate libraries. 10 | 11 | The `Ruby` API references are generated from the [Ably `Ruby` Client Library SDK source code](https://github.com/ably/ably-ruby) 12 | using [`yard`](https://yardoc.org/). View the [Ably docs](http://ably.com/docs/) for conceptual information on using Ably 13 | and for client library API references split between the [realtime](http://ably.com/docs/api/realtime-sdk) 14 | and [REST](http://ably.com/docs/api/rest-sdk) interfaces. 15 | -------------------------------------------------------------------------------- /spec/run_parallel_tests: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Run the unit tests first without RSpec parallel, then run acceptance tests in parallel 4 | # 5 | # When splitting all tests across all parallel processes, it's quite plausible 6 | # that some processes only run a majority of unit tests, whilst others only run a 7 | # a majority of acceptance tests. This ensures acceptance tests are split out. 8 | 9 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 10 | 11 | bundle exec rspec "${DIR}/unit" 12 | unit_status=$? 13 | 14 | bundle exec parallel_rspec "${DIR}/acceptance" --prefix-output-with-test-env-number 15 | 16 | acceptance_status=$? 17 | 18 | if [ $unit_status -ne 0 ]; then 19 | echo -e "\e[31m⚠ Note: Unit tests have also failed, but are not listed in the test failures above. Scroll up to the unit tests ⚠\e[0m" 20 | fi 21 | 22 | if [ $unit_status -ne 0 ] || [ $acceptance_status -ne 0 ]; then 23 | echo "Unit tests exit code: ${unit_status}" 24 | echo "Acceptance tests exit code: ${acceptance_status}" 25 | exit 1 26 | fi 27 | 28 | 29 | -------------------------------------------------------------------------------- /spec/unit/realtime/websocket_transport_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'shared/protocol_msgbus_behaviour' 3 | 4 | describe Ably::Realtime::Connection::WebsocketTransport, :api_private do 5 | let(:client_ignored) { double('Ably::Realtime::Client').as_null_object } 6 | let(:connection) { instance_double('Ably::Realtime::Connection', client: client_ignored, id: nil) } 7 | let(:url) { 'http://ably.io/' } 8 | 9 | let(:websocket_transport_without_eventmachine) do 10 | Ably::Realtime::Connection::WebsocketTransport.send(:allocate).tap do |websocket_transport| 11 | websocket_transport.send(:initialize, connection, url) 12 | end 13 | end 14 | 15 | before do 16 | allow(Ably::Realtime::Connection::WebsocketTransport).to receive(:new).with(connection).and_return(websocket_transport_without_eventmachine) 17 | end 18 | 19 | subject do 20 | Ably::Realtime::Connection::WebsocketTransport.new(connection) 21 | end 22 | 23 | it_behaves_like 'an incoming protocol message bus' 24 | it_behaves_like 'an outgoing protocol message bus' 25 | end 26 | -------------------------------------------------------------------------------- /lib/ably/models/message_encoders/json.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'ably/models/message_encoders/base' 3 | 4 | module Ably::Models::MessageEncoders 5 | # JSON Encoder and Decoder 6 | # Uses encoding identifier 'json' and encodes all objects that are not strings or byte arrays 7 | # 8 | class Json < Base 9 | ENCODING_ID = 'json' 10 | 11 | def encode(message, channel_options) 12 | if needs_json_encoding?(message) 13 | message[:data] = ::JSON.dump(message[:data]) 14 | add_encoding_to_message ENCODING_ID, message 15 | end 16 | end 17 | 18 | def decode(message, channel_options) 19 | if is_json_encoded?(message) 20 | message[:data] = ::JSON.parse(message[:data]) 21 | strip_current_encoding_part message 22 | end 23 | end 24 | 25 | private 26 | def needs_json_encoding?(message) 27 | !message[:data].kind_of?(String) && !message[:data].nil? 28 | end 29 | 30 | def is_json_encoded?(message) 31 | current_encoding_part(message).to_s.match(/^#{ENCODING_ID}$/i) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | deployments: write 11 | id-token: write 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: '2.7' 18 | bundler-cache: true 19 | 20 | - name: Build Documentation 21 | run: | 22 | bundle exec yard --readme INTRO.md --tag "spec:Specification" 23 | ls -al doc/ 24 | - name: Configure AWS Credentials 25 | uses: aws-actions/configure-aws-credentials@v1 26 | with: 27 | aws-region: eu-west-2 28 | role-to-assume: arn:aws:iam::${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }}:role/ably-sdk-builds-ably-ruby 29 | role-session-name: "${{ github.run_id }}-${{ github.run_number }}" 30 | 31 | - name: Upload Documentation 32 | uses: ably/sdk-upload-action@v1 33 | with: 34 | sourcePath: doc/ 35 | githubToken: ${{ secrets.GITHUB_TOKEN }} 36 | artifactName: docs 37 | -------------------------------------------------------------------------------- /lib/ably/realtime/recovery_key_context.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | # frozen_string_literal: true 3 | 4 | module Ably 5 | module Realtime 6 | class RecoveryKeyContext 7 | attr_reader :connection_key 8 | attr_reader :msg_serial 9 | attr_reader :channel_serials 10 | 11 | def initialize(connection_key, msg_serial, channel_serials) 12 | @connection_key = connection_key 13 | @msg_serial = msg_serial 14 | @channel_serials = channel_serials 15 | if @channel_serials.nil? 16 | @channel_serials = {} 17 | end 18 | end 19 | 20 | def to_json 21 | { 'connection_key' => @connection_key, 'msg_serial' => @msg_serial, 'channel_serials' => @channel_serials }.to_json 22 | end 23 | 24 | def self.from_json(obj, logger = nil) 25 | begin 26 | data = JSON.load obj 27 | self.new data['connection_key'], data['msg_serial'], data['channel_serials'] 28 | rescue => e 29 | logger.warn "unable to decode recovery key, found error #{e}" unless logger.nil? 30 | return nil 31 | end 32 | end 33 | 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/acceptance/realtime/stats_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ably::Realtime::Client, '#stats', :event_machine do 4 | vary_by_protocol do 5 | let(:client) do 6 | auto_close Ably::Realtime::Client.new(key: api_key, environment: environment, protocol: protocol) 7 | end 8 | 9 | describe 'fetching stats' do 10 | it 'returns a PaginatedResult' do 11 | client.stats do |stats| 12 | expect(stats).to be_a(Ably::Models::PaginatedResult) 13 | stop_reactor 14 | end 15 | end 16 | 17 | context 'with options' do 18 | let(:options) { { arbitrary: random_str } } 19 | 20 | it 'passes the option arguments to the REST stat method' do 21 | expect(client.rest_client).to receive(:stats).with(options) 22 | 23 | client.stats(options) do |stats| 24 | stop_reactor 25 | end 26 | end 27 | end 28 | 29 | it 'returns a SafeDeferrable that catches exceptions in callbacks and logs them' do 30 | expect(client.stats).to be_a(Ably::Util::SafeDeferrable) 31 | stop_reactor 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/support/test_logger_helper.rb: -------------------------------------------------------------------------------- 1 | # Class with standard Ruby Logger interface 2 | # but it keeps a record of the lof entries for later inspection 3 | # 4 | # Recommendation: Use :prevent_log_stubbing attibute on tests that use this logger 5 | # 6 | class TestLogger 7 | def initialize 8 | @messages = [] 9 | end 10 | 11 | SEVERITIES = [:fatal, :error, :warn, :info, :debug] 12 | SEVERITIES.each do |severity_sym| 13 | define_method(severity_sym) do |*args, &block| 14 | if block 15 | @messages << [severity_sym, block.call] 16 | else 17 | @messages << [severity_sym, args.join(', ')] 18 | end 19 | end 20 | end 21 | 22 | def logs(options = {}) 23 | min_severity = options[:min_severity] 24 | if min_severity 25 | severity_level = SEVERITIES.index(min_severity) 26 | raise "Unknown severity: #{min_severity}" if severity_level.nil? 27 | 28 | @messages.select do |severity, message| 29 | SEVERITIES.index(severity) <= severity_level 30 | end 31 | else 32 | @messages 33 | end 34 | end 35 | 36 | def level 37 | 1 38 | end 39 | 40 | def level=(new_level) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/support/private_api_formatter.rb: -------------------------------------------------------------------------------- 1 | module Ably::RSpec 2 | # PrivateApiFormatter is an RSpec Formatter that prefixes all tests that are part of a Private API with '(private)' 3 | # 4 | # Private API methods are tested for this library, but every implementation of the Ably client library 5 | # will likely be different and thus the private API method tests are not shared. 6 | # 7 | # Filter private API tests with `rspec --tag ~api_private` 8 | # 9 | class PrivateApiFormatter 10 | ::RSpec::Core::Formatters.register self, :example_started 11 | 12 | def initialize(output) 13 | @output = output 14 | end 15 | 16 | def example_started(notification) 17 | if notification.example.metadata[:api_private] 18 | notification.example.metadata[:description] = "#{yellow('(private)')} #{green(notification.example.metadata[:description])}" 19 | end 20 | end 21 | 22 | private 23 | def colorize(color_code, string) 24 | "\e[#{color_code}m#{string}\e[0m" 25 | end 26 | 27 | def yellow(string) 28 | colorize(33, string) 29 | end 30 | 31 | 32 | def green(string) 33 | colorize(32, string) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/ably/models/message_encoders/base64.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | require 'ably/models/message_encoders/base' 3 | 4 | module Ably::Models::MessageEncoders 5 | # Base64 binary Encoder and Decoder 6 | # Uses encoding identifier 'base64' 7 | # 8 | class Base64 < Base 9 | ENCODING_ID = 'base64' 10 | 11 | def encode(message, channel_options) 12 | return if is_empty?(message) 13 | 14 | if is_binary?(message) && transport_protocol_text? 15 | message[:data] = ::Base64.encode64(message[:data]) 16 | add_encoding_to_message ENCODING_ID, message 17 | end 18 | end 19 | 20 | def decode(message, channel_options) 21 | if is_base64_encoded?(message) 22 | message[:data] = ::Base64.decode64(message[:data]) 23 | strip_current_encoding_part message 24 | end 25 | end 26 | 27 | private 28 | def is_binary?(message) 29 | message[:data].kind_of?(String) && message[:data].encoding == Encoding::ASCII_8BIT 30 | end 31 | 32 | def is_base64_encoded?(message) 33 | current_encoding_part(message).to_s.match(/^#{ENCODING_ID}$/i) 34 | end 35 | 36 | def transport_protocol_text? 37 | !options[:binary_protocol] 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/unit/models/channel_metrics_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'shared/model_behaviour' 3 | 4 | describe Ably::Models::ChannelMetrics do 5 | subject { Ably::Models::ChannelMetrics(connections: 1, presence_connections: 2, presence_members: 2, presence_subscribers: 5, publishers: 7, subscribers: 9) } 6 | 7 | describe '#connections' do 8 | it 'should return integer' do 9 | expect(subject.connections).to eq(1) 10 | end 11 | end 12 | 13 | describe '#presence_connections' do 14 | it 'should return integer' do 15 | expect(subject.presence_connections).to eq(2) 16 | end 17 | end 18 | 19 | describe '#presence_members' do 20 | it 'should return integer' do 21 | expect(subject.presence_members).to eq(2) 22 | end 23 | end 24 | 25 | describe '#presence_subscribers' do 26 | it 'should return integer' do 27 | expect(subject.presence_subscribers).to eq(5) 28 | end 29 | end 30 | 31 | describe '#publishers' do 32 | it 'should return integer' do 33 | expect(subject.publishers).to eq(7) 34 | end 35 | end 36 | 37 | describe '#subscribers' do 38 | it 'should return integer' do 39 | expect(subject.subscribers).to eq(9) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/ably/modules/statesman_monkey_patch.rb: -------------------------------------------------------------------------------- 1 | module Ably::Modules 2 | # @api private 3 | module StatesmanMonkeyPatch 4 | # Override Statesman's #before_transition to support :from arrays 5 | # This can be removed once https://github.com/gocardless/statesman/issues/95 is solved 6 | def before_transition(options = nil, &block) 7 | arrayify_transition(options) do |options_without_from_array| 8 | super(*options_without_from_array, &block) 9 | end 10 | end 11 | 12 | # Override Statesman's #after_transition to support :from arrays 13 | # This can be removed once https://github.com/gocardless/statesman/issues/95 is solved 14 | def after_transition(options = nil, &block) 15 | arrayify_transition(options) do |options_without_from_array| 16 | super(*options_without_from_array, &block) 17 | end 18 | end 19 | 20 | private 21 | def arrayify_transition(options, &block) 22 | if options.nil? 23 | yield [] 24 | elsif options.fetch(:from, nil).kind_of?(Array) 25 | options[:from].each do |from_state| 26 | yield [options.merge(from: from_state)] 27 | end 28 | else 29 | yield [options] 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/ably/models/auth_details.rb: -------------------------------------------------------------------------------- 1 | module Ably::Models 2 | # Convert auth details attributes to a {AuthDetails} object 3 | # 4 | # @param attributes (see #initialize) 5 | # 6 | # @return [AuthDetails] 7 | # 8 | def self.AuthDetails(attributes) 9 | case attributes 10 | when AuthDetails 11 | return attributes 12 | else 13 | AuthDetails.new(attributes || {}) 14 | end 15 | end 16 | 17 | # AuthDetails are included in an +AUTH+ {Ably::Models::ProtocolMessage#auth} attribute 18 | # to provide the realtime service with new token authentication details following a re-auth workflow 19 | # 20 | class AuthDetails 21 | include Ably::Modules::ModelCommon 22 | 23 | # @param attributes [Hash] 24 | # @option attributes [String] :access_token token string 25 | # 26 | def initialize(attributes = {}) 27 | @hash_object = IdiomaticRubyWrapper(attributes.clone) 28 | self.attributes.freeze 29 | end 30 | 31 | # The authentication token string. 32 | # 33 | # @spec AD2 34 | # 35 | # @return [String] 36 | # 37 | def access_token 38 | attributes[:access_token] 39 | end 40 | 41 | def attributes 42 | @hash_object 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/ably/modules/ably.rb: -------------------------------------------------------------------------------- 1 | # Ably is the base namespace for the Ably {Ably::Realtime Realtime} & {Ably::Rest Rest} client libraries. 2 | # 3 | # Please refer to the {file:README.md Readme} on getting started. 4 | # 5 | # @see file:README.md README 6 | module Ably 7 | # Fallback hosts to use when a connection to rest/realtime.ably.io is not possible due to 8 | # network failures either at the client, between the client and Ably, within an Ably data center, or at the IO domain registrar 9 | # see https://ably.com/docs/client-lib-development-guide/features/#RSC15a 10 | # 11 | FALLBACK_DOMAIN = 'ably-realtime.com'.freeze 12 | FALLBACK_IDS = %w(a b c d e).freeze 13 | 14 | # Default production fallbacks a.ably-realtime.com ... e.ably-realtime.com 15 | FALLBACK_HOSTS = FALLBACK_IDS.map { |host| "#{host}.#{FALLBACK_DOMAIN}".freeze }.freeze 16 | 17 | # Custom environment default fallbacks {ENV}-a-fallback.ably-realtime.com ... {ENV}-a-fallback.ably-realtime.com 18 | CUSTOM_ENVIRONMENT_FALLBACKS_SUFFIXES = FALLBACK_IDS.map do |host| 19 | "-#{host}-fallback.#{FALLBACK_DOMAIN}".freeze 20 | end.freeze 21 | 22 | INTERNET_CHECK = { 23 | url: '//internet-up.ably-realtime.com/is-the-internet-up.txt', 24 | ok_text: 'yes' 25 | }.freeze 26 | end 27 | -------------------------------------------------------------------------------- /spec/support/api_helper.rb: -------------------------------------------------------------------------------- 1 | require 'support/test_app' 2 | 3 | module ApiHelper 4 | def app_id 5 | TestApp.instance.app_id 6 | end 7 | 8 | def key_name 9 | TestApp.instance.key_name 10 | end 11 | 12 | def key_secret 13 | TestApp.instance.key_secret 14 | end 15 | 16 | def api_key 17 | TestApp.instance.api_key 18 | end 19 | 20 | def restricted_api_key 21 | TestApp.instance.restricted_api_key 22 | end 23 | 24 | def environment 25 | TestApp.instance.environment 26 | end 27 | 28 | def reload_test_app 29 | WebMock.disable! 30 | TestApp.reload 31 | end 32 | 33 | def encode64(text) 34 | Base64.encode64(text).gsub("\n", '') 35 | end 36 | end 37 | 38 | RSpec.configure do |config| 39 | config.include ApiHelper 40 | 41 | config.before(:suite) do 42 | WebMock.disable! 43 | end 44 | 45 | config.after(:suite) do 46 | WebMock.disable! 47 | TestApp.instance.delete if TestApp.instance_variable_get('@singleton__instance__') 48 | end 49 | end 50 | 51 | module ApiPreloader 52 | def self.included(mod) 53 | WebMock.disable! 54 | TestApp.instance.api_key 55 | end 56 | 57 | RSpec.configure do |config| 58 | config.include self, :file_path => %r(spec/acceptance) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/support/event_emitter_helper.rb: -------------------------------------------------------------------------------- 1 | module Ably 2 | module Modules 3 | module EventEmitter 4 | # Unplugs currently registered listener callbacks 5 | # Ensures multiple calls to unplug is not destructive 6 | def unplug_listeners 7 | unplugged_callbacks[:callbacks] = unplugged_callbacks.fetch(:callbacks).merge(callbacks) 8 | unplugged_callbacks[:callbacks_any] = unplugged_callbacks.fetch(:callbacks_any) + callbacks_any 9 | callbacks.clear 10 | callbacks_any.clear 11 | end 12 | 13 | # Plug in previously unplugged listener callbacks 14 | # But merge them together in case other listners have been added in the mean time 15 | def plugin_listeners 16 | @callbacks = callbacks.merge(unplugged_callbacks.fetch(:callbacks)) 17 | @callbacks_any = callbacks_any + unplugged_callbacks.fetch(:callbacks_any) 18 | unplugged_callbacks.fetch(:callbacks).clear 19 | unplugged_callbacks.fetch(:callbacks_any).clear 20 | end 21 | 22 | private 23 | def unplugged_callbacks 24 | @unplugged_callbacks ||= { 25 | callbacks: Hash.new { |hash, key| hash[key] = [] }, 26 | callbacks_any: [] 27 | } 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/ably/realtime/channel/channel_properties.rb: -------------------------------------------------------------------------------- 1 | module Ably::Realtime 2 | class Channel 3 | # Describes the properties of the channel state. 4 | class ChannelProperties 5 | # {Ably::Realtime::Channel} this object associated with 6 | # 7 | # @return [Ably::Realtime::Channel] 8 | # 9 | attr_reader :channel 10 | 11 | # Starts unset when a channel is instantiated, then updated with the channelSerial from each 12 | # {Ably::Realtime::Channel::STATE.Attached} event that matches the channel. 13 | # Used as the value for {Ably::Realtime::Channel#history}. 14 | # 15 | # @spec CP2a 16 | # 17 | # @return [String] 18 | # 19 | attr_reader :attach_serial 20 | 21 | # ChannelSerial contains the channelSerial from latest ProtocolMessage of action type 22 | # Message/PresenceMessage received on the channel. 23 | # 24 | # @spec CP2b, RTL15b 25 | # 26 | # @return [String] 27 | # 28 | attr_accessor :channel_serial 29 | 30 | def initialize(channel) 31 | @channel = channel 32 | end 33 | 34 | # @api private 35 | def set_attach_serial(attach_serial) 36 | @attach_serial = attach_serial 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /UPDATING.md: -------------------------------------------------------------------------------- 1 | # Upgrade / Migration Guide 2 | 3 | ## Version 1.1.8 to 1.2.0 4 | 5 | ### Notable Changes 6 | This release is all about channel options. Here is the full [changelog](https://github.com/ably/ably-ruby/blob/main/CHANGELOG.md) 7 | 8 | * Channel options were extracted into a seperate model [ChannelOptions](https://github.com/ably/ably-ruby/blob/main/lib/ably/models/channel_options.rb). However it's still backward campatible with `Hash` and you don't need to do make any adjustments to your code 9 | 10 | * The `ChannelOptions` class now supports `:params`, `:modes` and `:cipher` as options. Previously only `:cipher` was available 11 | 12 | * The client `:idempotent_rest_publishing` option is `true` by default. Previously `:idempotent_rest_publishing` was `false` by default. 13 | 14 | ### Breaking Changes 15 | 16 | * Changing channel options with `Channels#get` is now deprecated in favor of explicit options change 17 | 18 | 1. If channel state is attached or attaching an exception will be raised 19 | 2. Otherwise the library will emit a warning 20 | 21 | For example, the following code 22 | ``` 23 | client.channels.get(channel_name, new_channel_options) 24 | ``` 25 | 26 | Should be changed to: 27 | ``` 28 | channel = client.channels.get(channel_name) 29 | channel.options = new_channel_options 30 | ``` 31 | -------------------------------------------------------------------------------- /lib/ably/rest.rb: -------------------------------------------------------------------------------- 1 | require 'ably/rest/channel' 2 | require 'ably/rest/channels' 3 | require 'ably/rest/client' 4 | require 'ably/rest/push' 5 | require 'ably/rest/presence' 6 | 7 | require 'ably/models/message_encoders/base' 8 | 9 | Dir.glob(File.expand_path("models/*.rb", File.dirname(__FILE__))).each do |file| 10 | require file 11 | end 12 | 13 | module Ably 14 | # Rest provides the top-level class to be instanced for the Ably Rest library 15 | # 16 | # @example 17 | # client = Ably::Rest.new("xxxxx") 18 | # channel = client.channel("test") 19 | # channel.publish "greeting", "data" 20 | # 21 | module Rest 22 | # Convenience method providing an alias to {Ably::Rest::Client} constructor. 23 | # 24 | # @param (see Ably::Rest::Client#initialize) 25 | # @option options (see Ably::Rest::Client#initialize) 26 | # 27 | # @return [Ably::Rest::Client] 28 | # 29 | # @example 30 | # # create a new client authenticating with basic auth 31 | # client = Ably::Rest.new('key.id:secret') 32 | # 33 | # # create a new client authenticating with basic auth and a client_id 34 | # client = Ably::Rest.new(key: 'key.id:secret', client_id: 'john') 35 | # 36 | def self.new(options) 37 | Ably::Rest::Client.new(options) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/shared/protocol_msgbus_behaviour.rb: -------------------------------------------------------------------------------- 1 | shared_examples 'a protocol message bus' do 2 | describe '__protocol_msgbus__ PubSub', :api_private do 3 | let(:protocol_message) do 4 | Ably::Models::ProtocolMessage.new( 5 | action: 15, 6 | channel: 'channel', 7 | msg_serial: 0, 8 | messages: [] 9 | ) 10 | end 11 | 12 | specify 'supports valid ProtocolMessage messages' do 13 | received = 0 14 | msgbus.subscribe(:protocol_message) { received += 1 } 15 | expect { msgbus.publish(:protocol_message, protocol_message) }.to change { received }.to(1) 16 | end 17 | 18 | specify 'fail with unacceptable STATE event names' do 19 | expect { msgbus.subscribe(:invalid) }.to raise_error KeyError 20 | expect { msgbus.publish(:invalid) }.to raise_error KeyError 21 | expect { msgbus.unsubscribe(:invalid) }.to raise_error KeyError 22 | end 23 | end 24 | end 25 | 26 | shared_examples 'an incoming protocol message bus' do 27 | it_behaves_like 'a protocol message bus' do 28 | let(:msgbus) { subject.__incoming_protocol_msgbus__ } 29 | end 30 | end 31 | 32 | shared_examples 'an outgoing protocol message bus' do 33 | it_behaves_like 'a protocol message bus' do 34 | let(:msgbus) { subject.__outgoing_protocol_msgbus__ } 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # Output the message to the console 2 | # Useful for debugging as clearly visible, and name is not used anywhere else in library as opposed to debug or puts 3 | def console(message) 4 | puts "\033[31m[#{Time.now.strftime('%H:%M:%S.%L')}]\033[0m \033[33m#{message}\033[0m" 5 | end 6 | 7 | unless RUBY_VERSION.match(/^1\./) 8 | require 'simplecov' 9 | 10 | SimpleCov.start do 11 | require 'simplecov-lcov' 12 | SimpleCov::Formatter::LcovFormatter.config do |c| 13 | c.report_with_single_file = true 14 | c.single_report_path = 'coverage/lcov.info' 15 | end 16 | formatter SimpleCov::Formatter::LcovFormatter 17 | add_filter %w[vendor spec] 18 | end 19 | end 20 | 21 | require 'webmock/rspec' 22 | 23 | require 'ably' 24 | 25 | require 'support/api_helper' 26 | require 'support/debug_failure_helper' 27 | require 'support/event_emitter_helper' 28 | require 'support/private_api_formatter' 29 | require 'support/protocol_helper' 30 | require 'support/random_helper' 31 | require 'support/serialization_helper' 32 | require 'support/test_logger_helper' 33 | 34 | require 'rspec_config' 35 | 36 | # EM Helper must be loaded after rspec_config to ensure around block occurs before RSpec retry 37 | require 'support/event_machine_helper' 38 | require 'support/rest_testapp_before_retry' 39 | -------------------------------------------------------------------------------- /lib/ably/models/channel_occupancy.rb: -------------------------------------------------------------------------------- 1 | module Ably::Models 2 | # Convert token details argument to a {ChannelOccupancy} object 3 | # 4 | # @param attributes (see #initialize) 5 | # 6 | # @return [ChannelOccupancy] 7 | # 8 | def self.ChannelOccupancy(attributes) 9 | case attributes 10 | when ChannelOccupancy 11 | return attributes 12 | else 13 | ChannelOccupancy.new(attributes) 14 | end 15 | end 16 | 17 | # Contains the metrics of a {Ably::Models::Rest::Channel} or {Ably::Models::Realtime::Channel} object. 18 | # 19 | # @spec CHO1 20 | # 21 | class ChannelOccupancy 22 | extend Ably::Modules::Enum 23 | extend Forwardable 24 | include Ably::Modules::ModelCommon 25 | 26 | # The attributes of ChannelOccupancy. 27 | # 28 | # @spec CH02 29 | # 30 | attr_reader :attributes 31 | 32 | alias_method :to_h, :attributes 33 | 34 | # Initialize a new ChannelOccupancy 35 | # 36 | def initialize(attrs) 37 | @attributes = IdiomaticRubyWrapper(attrs.clone) 38 | end 39 | 40 | # A {Ably::Models::ChannelMetrics} object. 41 | # 42 | # @spec CHO2a 43 | # 44 | # @return [Ably::Models::ChannelMetrics, nil] 45 | # 46 | def metrics 47 | Ably::Models::ChannelMetrics(attributes[:metrics]) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/unit/models/channel_details_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'shared/model_behaviour' 3 | 4 | describe Ably::Models::ChannelDetails do 5 | subject { Ably::Models::ChannelDetails(channel_id: 'channel-id-123-xyz', name: 'name', status: { isActive: 'true', occupancy: { metrics: { connections: 1, presence_connections: 2, presence_members: 2, presence_subscribers: 5, publishers: 7, subscribers: 9 } } }) } 6 | 7 | describe '#channel_id' do 8 | it 'should return channel id' do 9 | expect(subject.channel_id).to eq('channel-id-123-xyz') 10 | end 11 | end 12 | 13 | describe '#name' do 14 | it 'should return name' do 15 | expect(subject.name).to eq('name') 16 | end 17 | end 18 | 19 | describe '#status' do 20 | it 'should return status' do 21 | expect(subject.status).to be_a(Ably::Models::ChannelStatus) 22 | expect(subject.status.occupancy.metrics.connections).to eq(1) 23 | expect(subject.status.occupancy.metrics.presence_connections).to eq(2) 24 | expect(subject.status.occupancy.metrics.presence_members).to eq(2) 25 | expect(subject.status.occupancy.metrics.presence_subscribers).to eq(5) 26 | expect(subject.status.occupancy.metrics.publishers).to eq(7) 27 | expect(subject.status.occupancy.metrics.subscribers).to eq(9) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/ably/rest/middleware/encoder.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | require 'json' 3 | 4 | module Ably 5 | module Rest 6 | module Middleware 7 | # Encode the body of the message according to the mime type 8 | class Encoder < Faraday::Middleware 9 | CONTENT_TYPE = 'Content-Type'.freeze unless defined? CONTENT_TYPE 10 | 11 | def call(env) 12 | encode env if env.body 13 | @app.call env 14 | end 15 | 16 | private 17 | def encode(env) 18 | env.body = case request_type(env) 19 | when 'application/x-msgpack' 20 | to_msgpack(env.body) 21 | when 'application/json', '', nil 22 | env.request_headers[CONTENT_TYPE] = 'application/json' 23 | to_json(env.body) 24 | else 25 | env.body 26 | end 27 | end 28 | 29 | def to_msgpack(body) 30 | body.to_msgpack 31 | end 32 | 33 | def to_json(body) 34 | if body.kind_of?(String) 35 | body 36 | else 37 | body.to_json 38 | end 39 | end 40 | 41 | def request_type(env) 42 | type = env.request_headers[CONTENT_TYPE].to_s 43 | type = type.split(';', 2).first if type.index(';') 44 | type 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/acceptance/realtime/time_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ably::Realtime::Client, '#time', :event_machine do 4 | vary_by_protocol do 5 | let(:client) do 6 | auto_close Ably::Realtime::Client.new(key: api_key, environment: environment, protocol: protocol) 7 | end 8 | 9 | describe 'fetching the service time' do 10 | it 'should return the service time as a Time object' do 11 | run_reactor do 12 | client.time do |time| 13 | expect(time).to be_within(2).of(Time.now) 14 | stop_reactor 15 | end 16 | end 17 | end 18 | 19 | it 'returns a SafeDeferrable that catches exceptions in callbacks and logs them' do 20 | run_reactor do 21 | expect(client.time).to be_a(Ably::Util::SafeDeferrable) 22 | stop_reactor 23 | end 24 | end 25 | 26 | context 'with reconfigured HTTP timeout' do 27 | let(:client) do 28 | auto_close Ably::Realtime::Client.new(http_request_timeout: 0.0001, key: api_key, environment: environment, protocol: protocol, log_level: :fatal) 29 | end 30 | 31 | it 'should raise a timeout exception' do 32 | client.time.errback do |error| 33 | expect(error).to be_a Ably::Exceptions::ConnectionTimeout 34 | stop_reactor 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/support/debug_failure_helper.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | config.before(:example) do |example| 3 | next if example.metadata[:prevent_log_stubbing] 4 | 5 | log_mutex = Mutex.new 6 | 7 | @log_output = [] 8 | %w(fatal error warn info debug).each do |method_name| 9 | allow_any_instance_of(Ably::Logger).to receive(method_name.to_sym).and_wrap_original do |method, *args, &block| 10 | # Don't log shutdown sequence to keep log noise to a minimum 11 | next if RSpec.const_defined?(:EventMachine) && RSpec::EventMachine.reactor_stopping? 12 | 13 | prefix = "#{Time.now.strftime('%H:%M:%S.%L')} [\e[33m#{method_name}\e[0m] " 14 | 15 | log_mutex.synchronize do 16 | begin 17 | args << block.call unless block.nil? 18 | @log_output << "#{prefix}#{args.compact.join(' ')}" 19 | rescue StandardError => e 20 | @log_output << "#{prefix}Failed to log block - #{e.class}: #{e.message}\n#{e.backtrace.join("\n")}}" 21 | end 22 | end 23 | 24 | # Call original 25 | method.call(*args, &block) 26 | end 27 | end 28 | end 29 | 30 | config.after(:example) do |example| 31 | next if example.metadata[:prevent_log_stubbing] 32 | 33 | exception = example.exception 34 | puts "\n#{'-'*34}\n\e[36mVerbose Ably log from test failure\e[0m\n#{'-'*34}\n#{@log_output.join("\n")}\n\n" if exception 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/unit/rest/push_channel_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ably::Rest::Channel::PushChannel do 4 | subject { Ably::Rest::Channel::PushChannel } 5 | 6 | let(:channel_name) { 'unique' } 7 | let(:client) { double('client').as_null_object } 8 | let(:channel) { Ably::Rest::Channel.new(client, channel_name) } 9 | 10 | it 'is constructed with a channel' do 11 | expect(subject.new(channel)).to be_a(Ably::Rest::Channel::PushChannel) 12 | end 13 | 14 | it 'raises an exception if constructed with an invalid type' do 15 | expect { subject.new(Hash.new) }.to raise_error(ArgumentError) 16 | end 17 | 18 | it 'exposes the channel as attribute #channel' do 19 | expect(subject.new(channel).channel).to eql(channel) 20 | end 21 | 22 | it 'is available in the #push attribute of the channel' do 23 | expect(channel.push).to be_a(Ably::Rest::Channel::PushChannel) 24 | expect(channel.push.channel).to eql(channel) 25 | end 26 | 27 | context 'methods not implemented as push notifications' do 28 | subject { Ably::Rest::Channel::PushChannel.new(channel) } 29 | 30 | %w(subscribe_device subscribe_client_id unsubscribe_device unsubscribe_client_id get_subscriptions).each do |method_name| 31 | specify "##{method_name} raises an unsupported exception" do 32 | expect { subject.public_send(method_name, 'foo') }.to raise_error(Ably::Exceptions::PushNotificationsNotSupported) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/ably/modules/http_helpers.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | 3 | require 'ably/version' 4 | 5 | require 'ably/rest/middleware/encoder' 6 | require 'ably/rest/middleware/external_exceptions' 7 | require 'ably/rest/middleware/fail_if_unsupported_mime_type' 8 | require 'ably/rest/middleware/logger' 9 | require 'ably/rest/middleware/parse_json' 10 | require 'ably/rest/middleware/parse_message_pack' 11 | 12 | module Ably::Modules 13 | # HttpHelpers provides common private methods to classes to simplify HTTP interactions with Ably 14 | module HttpHelpers 15 | protected 16 | def encode64(text) 17 | Base64.encode64(text).gsub("\n", '') 18 | end 19 | 20 | def user_agent 21 | "Ably Ruby client #{Ably::VERSION} (https://www.ably.io)" 22 | end 23 | 24 | def setup_outgoing_middleware(builder) 25 | # Convert request params to "www-form-urlencoded" 26 | builder.use Ably::Rest::Middleware::Encoder 27 | end 28 | 29 | def setup_incoming_middleware(builder, logger, options = {}) 30 | builder.use Ably::Rest::Middleware::Logger, logger 31 | 32 | # Parse JSON / MsgPack response bodies. ParseJson must be first (default) parsing middleware 33 | if options[:fail_if_unsupported_mime_type] == true 34 | builder.use Ably::Rest::Middleware::FailIfUnsupportedMimeType 35 | end 36 | 37 | builder.use Ably::Rest::Middleware::ParseJson 38 | builder.use Ably::Rest::Middleware::ParseMessagePack 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/unit/realtime/incoming_message_dispatcher_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ably::Realtime::Client::IncomingMessageDispatcher, :api_private do 4 | let(:msgbus) do 5 | Ably::Util::PubSub.new 6 | end 7 | let(:connection) do 8 | instance_double('Ably::Realtime::Connection', __incoming_protocol_msgbus__: msgbus, configure_new: true, id: nil, set_connection_confirmed_alive: nil) 9 | end 10 | let(:client) do 11 | instance_double('Ably::Realtime::Client', channels: {}) 12 | end 13 | 14 | subject { Ably::Realtime::Client::IncomingMessageDispatcher.new(client, connection) } 15 | 16 | context '#initialize' do 17 | it 'should subscribe to protocol messages from the connection' do 18 | expect(msgbus).to receive(:subscribe).with(:protocol_message).and_call_original 19 | subject 20 | end 21 | end 22 | 23 | context '#dispatch_protocol_message' do 24 | before { subject } 25 | 26 | it 'should raise an exception if a message is sent that is not a ProtocolMessage' do 27 | expect { msgbus.publish :protocol_message, nil }.to raise_error ArgumentError 28 | end 29 | 30 | it 'should warn if a message is received for a non-existent channel' do 31 | allow(subject).to receive_message_chain(:logger, :debug) 32 | expect(subject).to receive_message_chain(:logger, :warn) 33 | msgbus.publish :protocol_message, Ably::Models::ProtocolMessage.new(:action => :attached, channel: 'unknown') 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/unit/realtime/push_channel_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ably::Realtime::Channel::PushChannel do 4 | subject { Ably::Realtime::Channel::PushChannel } 5 | 6 | let(:channel_name) { 'unique' } 7 | let(:client) { double('client').as_null_object } 8 | let(:channel) { Ably::Realtime::Channel.new(client, channel_name) } 9 | 10 | it 'is constructed with a channel' do 11 | expect(subject.new(channel)).to be_a(Ably::Realtime::Channel::PushChannel) 12 | end 13 | 14 | it 'raises an exception if constructed with an invalid type' do 15 | expect { subject.new(Hash.new) }.to raise_error(ArgumentError) 16 | end 17 | 18 | it 'exposes the channel as attribute #channel' do 19 | expect(subject.new(channel).channel).to eql(channel) 20 | end 21 | 22 | it 'is available in the #push attribute of the channel' do 23 | expect(channel.push).to be_a(Ably::Realtime::Channel::PushChannel) 24 | expect(channel.push.channel).to eql(channel) 25 | end 26 | 27 | context 'methods not implemented as push notifications' do 28 | subject { Ably::Realtime::Channel::PushChannel.new(channel) } 29 | 30 | %w(subscribe_device subscribe_client_id unsubscribe_device unsubscribe_client_id get_subscriptions).each do |method_name| 31 | specify "##{method_name} raises an unsupported exception" do 32 | expect { subject.public_send(method_name, 'foo') }.to raise_error(Ably::Exceptions::PushNotificationsNotSupported) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/ably/util/pub_sub.rb: -------------------------------------------------------------------------------- 1 | require 'ably/modules/event_emitter.rb' 2 | 3 | module Ably::Util 4 | # PubSub class provides methods to publish & subscribe to events, with methods and naming 5 | # intentionally different to EventEmitter as it is intended for private message handling 6 | # within the client library. 7 | # 8 | # @example 9 | # class Channel 10 | # def messages 11 | # @messages ||= PubSub.new 12 | # end 13 | # end 14 | # 15 | # channel = Channel.new 16 | # channel.messages.subscribe(:event) { |name| puts "Event message #{name} received" } 17 | # channel.messages.publish :event, "Test" 18 | # #=> "Event message Test received" 19 | # channel.messages.remove :event 20 | # 21 | class PubSub 22 | include Ably::Modules::EventEmitter 23 | 24 | # Ensure new PubSub object does not share class instance variables 25 | def self.new(options = {}) 26 | Class.new(PubSub).allocate.tap do |pub_sub_object| 27 | pub_sub_object.send(:initialize, options) 28 | end 29 | end 30 | 31 | def inspect 32 | "<#PubSub: @event_emitter_coerce_proc: #{self.class.event_emitter_coerce_proc.inspect}\n @callbacks: #{callbacks}>" 33 | end 34 | 35 | def initialize(options = {}) 36 | self.class.instance_eval do 37 | configure_event_emitter options 38 | 39 | alias_method :subscribe, :unsafe_on 40 | alias_method :publish, :emit 41 | alias_method :unsubscribe, :unsafe_off 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/unit/realtime/connection_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'shared/protocol_msgbus_behaviour' 3 | 4 | describe Ably::Realtime::Connection do 5 | let(:client) { instance_double('Ably::Realtime::Client', logger: double('logger').as_null_object, recover: nil, endpoint: double('endpoint', host: 'realtime.ably.io')) } 6 | 7 | subject do 8 | Ably::Realtime::Connection.new(client, {}).tap do |connection| 9 | connection.__incoming_protocol_msgbus__.unsubscribe 10 | connection.__outgoing_protocol_msgbus__.unsubscribe 11 | end 12 | end 13 | 14 | before do 15 | expect(EventMachine).to receive(:next_tick) # non_blocking_loop_while for delivery of messages async 16 | subject.__incoming_protocol_msgbus__.off 17 | subject.__outgoing_protocol_msgbus__.off 18 | end 19 | 20 | describe 'callbacks' do 21 | specify 'are supported for valid STATE events' do 22 | state = nil 23 | subject.on(:initialized) { state = :ready } 24 | expect { subject.emit(:initialized) }.to change { state }.to(:ready) 25 | end 26 | 27 | specify 'fail with unacceptable STATE event names' do 28 | expect { subject.on(:invalid) }.to raise_error KeyError 29 | expect { subject.emit(:invalid) }.to raise_error KeyError 30 | expect { subject.off(:invalid) }.to raise_error KeyError 31 | end 32 | end 33 | 34 | it_behaves_like 'an incoming protocol message bus' 35 | it_behaves_like 'an outgoing protocol message bus' 36 | 37 | after(:all) do 38 | sleep 1 # let realtime library shut down any open clients 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/ably/util/safe_deferrable.rb: -------------------------------------------------------------------------------- 1 | module Ably::Util 2 | # SafeDeferrable class provides a Deferrable that is safe to use for for public interfaces 3 | # of this client library. Any exceptions raised in the success or failure callbacks are 4 | # caught and logged to the provided logger. 5 | # 6 | # An exception in a callback provided by a developer should not break this client library 7 | # and stop further execution of code. 8 | # 9 | class SafeDeferrable 10 | include Ably::Modules::SafeDeferrable 11 | 12 | attr_reader :logger 13 | 14 | def initialize(logger) 15 | @logger = logger 16 | end 17 | 18 | # Create a new {SafeDeferrable} and fail immediately with the provided error in the next eventloop cycle 19 | # 20 | # @param error [Ably::Exceptions::BaseAblyException, Ably::Models::ErrorInfo] The error used to fail the newly created {SafeDeferrable} 21 | # 22 | # @return [SafeDeferrable] 23 | # 24 | def self.new_and_fail_immediately(logger, error) 25 | new(logger).tap do |deferrable| 26 | EventMachine.next_tick do 27 | deferrable.fail error 28 | end 29 | end 30 | end 31 | 32 | # Create a new {SafeDeferrable} and succeed immediately with the provided arguments in the next eventloop cycle 33 | # 34 | # @return [SafeDeferrable] 35 | # 36 | def self.new_and_succeed_immediately(logger, *args) 37 | new(logger).tap do |deferrable| 38 | EventMachine.next_tick do 39 | deferrable.succeed *args 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/ably/modules/safe_yield.rb: -------------------------------------------------------------------------------- 1 | module Ably::Modules 2 | # SafeYield provides the method safe_yield that will yield to the consumer 3 | # who provided a block, however any exceptions will be caught, logged, and 4 | # operation of the client library will continue. 5 | # 6 | # An exception in a callback provided by a developer should not break this client library 7 | # and stop further execution of code. 8 | # 9 | # @note this Module requires that the method #logger is available 10 | # 11 | # @api private 12 | module SafeYield 13 | private 14 | 15 | def safe_yield(block, *args) 16 | block.call(*args) 17 | rescue StandardError => e 18 | message = "An exception in an external block was caught. #{e.class}: #{e.message}\n#{e.backtrace.join("\n")}" 19 | safe_yield_log_error message 20 | end 21 | 22 | def safe_yield_log_error(message) 23 | if defined?(:logger) && logger.respond_to?(:error) 24 | return logger.error message 25 | end 26 | rescue StandardError 27 | fallback_logger.error message 28 | end 29 | 30 | def fallback_logger 31 | @fallback_logger ||= ::Logger.new(STDOUT).tap do |logger| 32 | logger.formatter = lambda do |severity, datetime, progname, msg| 33 | [ 34 | "#{datetime.strftime("%Y-%m-%d %H:%M:%S.%L")} #{::Logger::SEV_LABEL[severity]} #{msg}", 35 | "Warning: SafeYield expects the method #logger to be defined in the class it is included in, the method was not found in #{self.class}" 36 | ].join("\n") 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/ably/models/channel_status.rb: -------------------------------------------------------------------------------- 1 | module Ably::Models 2 | # Convert token details argument to a {ChannelStatus} object 3 | # 4 | # @param attributes (see #initialize) 5 | # 6 | # @return [ChannelStatus] 7 | # 8 | def self.ChannelStatus(attributes) 9 | case attributes 10 | when ChannelStatus 11 | return attributes 12 | else 13 | ChannelStatus.new(attributes) 14 | end 15 | end 16 | 17 | # Contains the status of a {Ably::Models::Rest::Channel} or {Ably::Models::Realtime::Channel} object 18 | # such as whether it is active and its {Ably::Models::ChannelOccupancy}. 19 | # 20 | # @spec CHS1 21 | # 22 | class ChannelStatus 23 | extend Ably::Modules::Enum 24 | extend Forwardable 25 | include Ably::Modules::ModelCommon 26 | 27 | # The attributes of ChannelStatus 28 | # 29 | # @spec CHS2 30 | # 31 | attr_reader :attributes 32 | 33 | alias_method :to_h, :attributes 34 | 35 | # Initialize a new ChannelStatus 36 | # 37 | def initialize(attrs) 38 | @attributes = IdiomaticRubyWrapper(attrs.clone) 39 | end 40 | 41 | # If true, the channel is active, otherwise false. 42 | # 43 | # @spec CHS2a 44 | # 45 | # @return [Boolean] 46 | # 47 | def is_active 48 | attributes[:isActive] 49 | end 50 | alias_method :active?, :is_active 51 | alias_method :is_active?, :is_active 52 | 53 | # A {Ably::Models::ChannelOccupancy} object. 54 | # 55 | # @spec CHS2b 56 | # 57 | # @return [Ably::Models::ChannelOccupancy, nil] 58 | # 59 | def occupancy 60 | Ably::Models::ChannelOccupancy(attributes[:occupancy]) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/unit/realtime/recovery_key_context_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'ably/realtime/recovery_key_context' 3 | 4 | describe Ably::Realtime::RecoveryKeyContext do 5 | 6 | context 'connection recovery key' do 7 | 8 | it 'should encode recovery key - RTN16i, RTN16f, RTN16j' do 9 | connection_key = 'key' 10 | msg_serial = 123 11 | channel_serials = { 12 | 'channel1' => 'serial1', 13 | 'channel2' => 'serial2' 14 | } 15 | recovery_context = Ably::Realtime::RecoveryKeyContext.new(connection_key, msg_serial, channel_serials) 16 | encoded_recovery_key = recovery_context.to_json 17 | expect(encoded_recovery_key).to eq "{\"connection_key\":\"key\",\"msg_serial\":123," << 18 | "\"channel_serials\":{\"channel1\":\"serial1\",\"channel2\":\"serial2\"}}" 19 | end 20 | 21 | it 'should decode recovery key - RTN16i, RTN16f, RTN16j' do 22 | encoded_recovery_key = "{\"connection_key\":\"key\",\"msg_serial\":123," << 23 | "\"channel_serials\":{\"channel1\":\"serial1\",\"channel2\":\"serial2\"}}" 24 | decoded_recovery_key = Ably::Realtime::RecoveryKeyContext.from_json(encoded_recovery_key) 25 | expect(decoded_recovery_key.connection_key).to eq("key") 26 | expect(decoded_recovery_key.msg_serial).to eq(123) 27 | end 28 | 29 | it 'should return nil for invalid recovery key - RTN16i, RTN16f, RTN16j' do 30 | encoded_recovery_key = "{\"invalid key\"}" 31 | decoded_recovery_key = Ably::Realtime::RecoveryKeyContext.from_json(encoded_recovery_key) 32 | expect(decoded_recovery_key).to be_nil 33 | end 34 | 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/ably/models/channel_details.rb: -------------------------------------------------------------------------------- 1 | module Ably::Models 2 | # Convert token details argument to a {ChannelDetails} object 3 | # 4 | # @param attributes (see #initialize) 5 | # 6 | # @return [ChannelDetails] 7 | # 8 | def self.ChannelDetails(attributes) 9 | case attributes 10 | when ChannelDetails 11 | return attributes 12 | else 13 | ChannelDetails.new(attributes) 14 | end 15 | end 16 | 17 | # Contains the details of a {Ably::Models::Rest::Channel} or {Ably::Models::Realtime::Channel} object 18 | # such as its ID and {Ably::Models::ChannelStatus}. 19 | # 20 | class ChannelDetails 21 | extend Ably::Modules::Enum 22 | extend Forwardable 23 | include Ably::Modules::ModelCommon 24 | 25 | # The attributes of ChannelDetails 26 | # 27 | # @spec CHD2 28 | # 29 | attr_reader :attributes 30 | 31 | alias_method :to_h, :attributes 32 | 33 | # Initialize a new ChannelDetails 34 | # 35 | def initialize(attrs) 36 | @attributes = IdiomaticRubyWrapper(attrs.clone) 37 | end 38 | 39 | # The identifier of the channel 40 | # 41 | # @spec CHD2a 42 | # 43 | # @return [String] 44 | # 45 | def channel_id 46 | attributes[:channel_id] 47 | end 48 | 49 | # The identifier of the channel 50 | # 51 | # @spec CHD2a 52 | # 53 | # @return [String] 54 | # 55 | def name 56 | attributes[:name] 57 | end 58 | 59 | # A {Ably::Models::ChannelStatus} object. 60 | # 61 | # @spec CHD2b 62 | # 63 | # @return [Ably::Models::ChannelStatus, nil] 64 | # 65 | def status 66 | Ably::Models::ChannelStatus(attributes[:status]) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/ably/rest/middleware/logger.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | 3 | module Ably 4 | module Rest 5 | module Middleware 6 | class Logger < Faraday::Middleware 7 | extend Forwardable 8 | 9 | def initialize(app, logger = nil) 10 | super(app) 11 | @logger = logger || begin 12 | require 'logger' 13 | ::Logger.new(STDOUT) 14 | end 15 | end 16 | 17 | def_delegators :@logger, :debug, :info, :warn, :error, :fatal 18 | 19 | def call(env) 20 | debug { "=> URL: #{env.method} #{env.url}, Headers: #{dump_headers env.request_headers}" } 21 | debug { "=> Body: #{body_for(env)}" } 22 | super 23 | end 24 | 25 | def on_complete(env) 26 | debug "<= Status: #{env.status}, Headers: #{dump_headers env.response_headers}" 27 | debug "<= Body: #{body_for(env)}" 28 | end 29 | 30 | private 31 | def dump_headers(headers) 32 | headers.map { |k, v| "#{k}: #{v.inspect}" }.join(", ") 33 | end 34 | 35 | def body_for(env) 36 | return '' if !env.body || env.body.empty? 37 | 38 | if env.request_headers['Content-Type'] == 'application/x-msgpack' 39 | MessagePack.unpack(env.body) 40 | else 41 | env.body 42 | end 43 | 44 | rescue StandardError 45 | readable_body(env.body) 46 | end 47 | 48 | def readable_body(body) 49 | if body.respond_to?(:encoding) && body.encoding == Encoding::ASCII_8BIT 50 | body.unpack('H*') 51 | else 52 | body 53 | end 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/unit/models/auth_details_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'shared/model_behaviour' 3 | 4 | describe Ably::Models::AuthDetails do 5 | include Ably::Modules::Conversions 6 | 7 | subject { Ably::Models::AuthDetails } 8 | 9 | # Spec model items AD2* 10 | it_behaves_like 'a model', with_simple_attributes: %w(access_token) do 11 | let(:model_args) { [] } 12 | end 13 | 14 | context '==' do 15 | let(:attributes) { { access_token: 'unique' } } 16 | 17 | it 'is true when attributes are the same' do 18 | auth_details = -> { Ably::Models::AuthDetails.new(attributes) } 19 | expect(auth_details.call).to eq(auth_details.call) 20 | end 21 | 22 | it 'is false when attributes are not the same' do 23 | expect(Ably::Models::AuthDetails.new(access_token: '1')).to_not eq(Ably::Models::AuthDetails.new(access_token: '2')) 24 | end 25 | 26 | it 'is false when class type differs' do 27 | expect(Ably::Models::AuthDetails.new(access_token: '1')).to_not eq(nil) 28 | end 29 | end 30 | 31 | context 'AuthDetails conversion methods', :api_private do 32 | context 'with a AuthDetails object' do 33 | let(:details) { Ably::Models::AuthDetails.new(access_token: random_str) } 34 | 35 | it 'returns the AuthDetails object' do 36 | expect(Ably::Models::AuthDetails(details)).to eql(details) 37 | end 38 | end 39 | 40 | context 'with a JSON object' do 41 | let(:access_token) { random_str } 42 | let(:details_json) { { access_token: access_token } } 43 | 44 | it 'returns a new AuthDetails object from the JSON' do 45 | expect(Ably::Models::AuthDetails(details_json).access_token).to eql(access_token) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/unit/models/channel_status_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'shared/model_behaviour' 3 | 4 | describe Ably::Models::ChannelStatus do 5 | subject { Ably::Models::ChannelStatus({ isActive: 'true', occupancy: { metrics: { connections: 1, presence_connections: 2, presence_members: 2, presence_subscribers: 5, publishers: 7, subscribers: 9 } } }) } 6 | 7 | describe '#is_active' do 8 | context 'when occupancy is active' do 9 | subject { Ably::Models::ChannelStatus({ isActive: true, occupancy: { metrics: { connections: 1, presence_connections: 2, presence_members: 2, presence_subscribers: 5, publishers: 7, subscribers: 9 } } }) } 10 | 11 | it 'should return true' do 12 | expect(subject.is_active).to eq(true) 13 | end 14 | end 15 | 16 | context 'when occupancy is not active' do 17 | subject { Ably::Models::ChannelStatus({ isActive: false, occupancy: { metrics: { connections: 1, presence_connections: 2, presence_members: 2, presence_subscribers: 5, publishers: 7, subscribers: 9 } } }) } 18 | 19 | it 'should return false' do 20 | expect(subject.is_active).to eq(false) 21 | end 22 | end 23 | end 24 | 25 | describe '#occupancy' do 26 | it 'should return occupancy object' do 27 | expect(subject.occupancy).to be_a(Ably::Models::ChannelOccupancy) 28 | expect(subject.occupancy.metrics.connections).to eq(1) 29 | expect(subject.occupancy.metrics.presence_connections).to eq(2) 30 | expect(subject.occupancy.metrics.presence_members).to eq(2) 31 | expect(subject.occupancy.metrics.presence_subscribers).to eq(5) 32 | expect(subject.occupancy.metrics.publishers).to eq(7) 33 | expect(subject.occupancy.metrics.subscribers).to eq(9) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/ably/rest/middleware/parse_message_pack.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | require 'msgpack' 3 | 4 | module Ably 5 | module Rest 6 | module Middleware 7 | class ParseMessagePack < Faraday::Middleware 8 | def on_complete(env) 9 | if env.response_headers['Content-Type'] == 'application/x-msgpack' 10 | env.body = parse(env.body) unless env.response_headers['Ably-Middleware-Parsed'] == true 11 | env.response_headers['Ably-Middleware-Parsed'] = true 12 | end 13 | rescue Ably::Exceptions::InvalidResponseBody => e 14 | debug_info = { 15 | method: env.method, 16 | url: env.url, 17 | base64_body: base64_body(env.body), 18 | response_headers: env.response_headers 19 | } 20 | raise Ably::Exceptions::InvalidResponseBody, "#{e.message}\nRequest env: #{debug_info}" 21 | end 22 | 23 | def parse(body) 24 | if body.length > 0 25 | MessagePack.unpack(body) 26 | else 27 | body 28 | end 29 | rescue MessagePack::UnknownExtTypeError => e 30 | raise Ably::Exceptions::InvalidResponseBody, "MessagePack::UnknownExtTypeError body could not be decoded: #{e.message}. Got Base64:\n#{base64_body(body)}" 31 | rescue MessagePack::MalformedFormatError => e 32 | raise Ably::Exceptions::InvalidResponseBody, "MessagePack::MalformedFormatError body could not be decoded: #{e.message}. Got Base64:\n#{base64_body(body)}" 33 | end 34 | 35 | def base64_body(body) 36 | Base64.encode64(body) 37 | rescue => err 38 | "[#{err.message}! Could not base64 encode body: '#{body}']" 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/ably/rest/channels.rb: -------------------------------------------------------------------------------- 1 | module Ably 2 | module Rest 3 | class Channels 4 | include Ably::Modules::ChannelsCollection 5 | 6 | # @return [Ably::Rest::Channels] 7 | def initialize(client) 8 | super client, Ably::Rest::Channel 9 | end 10 | 11 | # Return a {Ably::Rest::Channel} for the given name 12 | # 13 | # @param name [String] The name of the channel 14 | # @param channel_options [Hash] Channel options, currently reserved for Encryption options 15 | # 16 | # @return [Ably::Rest::Channel] 17 | # 18 | def get(*args) 19 | super 20 | end 21 | 22 | # Return a {Ably::Rest::Channel} for the given name if it exists, else the block will be called. 23 | # This method is intentionally similar to {http://ruby-doc.org/core-2.1.3/Hash.html#method-i-fetch Hash#fetch} providing a simple way to check if a channel exists or not without creating one 24 | # 25 | # @param name [String] The name of the channel 26 | # @yield [options] (optional) if a missing_block is passed to this method and no channel exists matching the name, this block is called 27 | # @yieldparam [String] name of the missing channel 28 | # 29 | # @return [Ably::Rest::Channel] 30 | # 31 | def fetch(*args) 32 | super 33 | end 34 | 35 | # Destroy the {Ably::Rest::Channel} and releases the associated resources. 36 | # 37 | # Releasing a {Ably::Rest::Channel} is not typically necessary as a channel consumes no resources other than the memory footprint of the 38 | # {Ably::Rest::Channel} object. Explicitly release channels to free up resources if required 39 | # 40 | # @return [void] 41 | # 42 | def release(*args) 43 | super 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/unit/models/message_encoders/utf8_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'ably/models/message_encoders/utf8' 4 | 5 | describe Ably::Models::MessageEncoders::Utf8 do 6 | let(:string_ascii) { 'string'.encode(Encoding::ASCII_8BIT) } 7 | let(:string_utf8) { 'string'.encode(Encoding::UTF_8) } 8 | 9 | let(:client) { instance_double('Ably::Realtime::Client') } 10 | 11 | subject { Ably::Models::MessageEncoders::Utf8.new(client) } 12 | 13 | context '#decode' do 14 | before do 15 | subject.decode message, {} 16 | end 17 | 18 | context 'message with utf8 payload' do 19 | let(:message) { { data: string_ascii, encoding: 'utf-8' } } 20 | 21 | it 'sets the encoding' do 22 | expect(message[:data]).to eq(string_utf8) 23 | expect(message[:data].encoding).to eql(Encoding::UTF_8) 24 | end 25 | 26 | it 'strips the encoding' do 27 | expect(message[:encoding]).to be_nil 28 | end 29 | end 30 | 31 | context 'message with utf8 payload before other payloads' do 32 | let(:message) { { data: string_utf8, encoding: 'json/utf-8' } } 33 | 34 | it 'sets the encoding' do 35 | expect(message[:data]).to eql(string_utf8) 36 | expect(message[:data].encoding).to eql(Encoding::UTF_8) 37 | end 38 | 39 | it 'strips the encoding' do 40 | expect(message[:encoding]).to eql('json') 41 | end 42 | end 43 | 44 | context 'message with another payload' do 45 | let(:message) { { data: string_ascii, encoding: 'json' } } 46 | 47 | it 'leaves the message data intact' do 48 | expect(message[:data]).to eql(string_ascii) 49 | end 50 | 51 | it 'leaves the encoding intact' do 52 | expect(message[:encoding]).to eql('json') 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/ably/modules/state_machine.rb: -------------------------------------------------------------------------------- 1 | require 'statesman' 2 | require 'ably/modules/statesman_monkey_patch' 3 | 4 | module Ably::Modules 5 | # Module providing Statesman StateMachine functionality 6 | # 7 | # Expects method #logger to be defined 8 | # 9 | # @api private 10 | module StateMachine 11 | def self.included(klass) 12 | klass.class_eval do 13 | include Statesman::Machine 14 | end 15 | klass.extend Ably::Modules::StatesmanMonkeyPatch 16 | klass.extend ClassMethods 17 | end 18 | 19 | # Alternative to Statesman's #transition_to that: 20 | # * log state change failures to {Logger} 21 | # 22 | # @return [void] 23 | # 24 | def transition_state(state, *args) 25 | unless result = transition_to(state.to_sym, *args) 26 | exception = exception_for_state_change_to(state) 27 | logger.fatal { "#{self.class}: #{exception.message}\n#{caller[0..20].join("\n")}" } 28 | end 29 | result 30 | end 31 | 32 | # @return [Statesman History Object] 33 | # 34 | def previous_transition 35 | history[-2] 36 | end 37 | 38 | # @return [Symbol] 39 | # 40 | def previous_state 41 | previous_transition.to_state if previous_transition 42 | end 43 | 44 | # @return [Ably::Exceptions::InvalidStateChange] 45 | # 46 | def exception_for_state_change_to(state) 47 | error_message = "#{self.class}: Unable to transition from #{current_state} => #{state}" 48 | Ably::Exceptions::InvalidStateChange.new(error_message, nil, Ably::Exceptions::Codes::CHANNEL_OPERATION_FAILED_INVALID_CHANNEL_STATE) 49 | end 50 | 51 | module ClassMethods 52 | private 53 | 54 | def is_error_type?(error) 55 | error.kind_of?(Ably::Models::ErrorInfo) || error.kind_of?(StandardError) 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/ably/realtime.rb: -------------------------------------------------------------------------------- 1 | require 'eventmachine' 2 | require 'websocket/driver' 3 | require 'em-http-request' 4 | 5 | require 'ably/modules/event_emitter' 6 | 7 | require 'ably/realtime/auth' 8 | require 'ably/realtime/channel' 9 | require 'ably/realtime/channels' 10 | require 'ably/realtime/client' 11 | require 'ably/realtime/connection' 12 | require 'ably/realtime/push' 13 | require 'ably/realtime/presence' 14 | 15 | require 'ably/models/message_encoders/base' 16 | 17 | Dir.glob(File.expand_path("models/*.rb", File.dirname(__FILE__))).each do |file| 18 | require file 19 | end 20 | 21 | Dir.glob(File.expand_path("realtime/models/*.rb", File.dirname(__FILE__))).each do |file| 22 | require file 23 | end 24 | 25 | require 'ably/models/message_encoders/base' 26 | 27 | require 'ably/realtime/client/incoming_message_dispatcher' 28 | require 'ably/realtime/client/outgoing_message_dispatcher' 29 | 30 | module Ably 31 | # Realtime provides the top-level class to be instanced for the Ably Realtime library 32 | # 33 | # @example 34 | # client = Ably::Realtime.new("xxxxx") 35 | # channel = client.channel("test") 36 | # channel.subscribe do |message| 37 | # message[:name] #=> "greeting" 38 | # end 39 | # channel.publish "greeting", "data" 40 | # 41 | module Realtime 42 | # Convenience method providing an alias to {Ably::Realtime::Client} constructor. 43 | # 44 | # @param (see Ably::Realtime::Client#initialize) 45 | # @option options (see Ably::Realtime::Client#initialize) 46 | # 47 | # @return [Ably::Realtime::Client] 48 | # 49 | # @example 50 | # # create a new client authenticating with basic auth 51 | # client = Ably::Realtime.new('key.id:secret') 52 | # 53 | # # create a new client authenticating with basic auth and a client_id 54 | # client = Ably::Realtime.new(key: 'key.id:secret', client_id: 'john') 55 | # 56 | def self.new(options) 57 | Ably::Realtime::Client.new(options) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/lib/unit/models/channel_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Ably::Models::ChannelOptions do 6 | let(:modes) { nil } 7 | let(:params) { {} } 8 | let(:options) { described_class.new(modes: modes, params: params) } 9 | 10 | describe '#modes_to_flags' do 11 | let(:modes) { %w[publish subscribe presence_subscribe] } 12 | 13 | subject(:protocol_message) do 14 | Ably::Models::ProtocolMessage.new(action: Ably::Models::ProtocolMessage::ACTION.Attach, flags: options.modes_to_flags) 15 | end 16 | 17 | it 'converts modes to ProtocolMessage#flags correctly' do 18 | expect(protocol_message.has_attach_publish_flag?).to eq(true) 19 | expect(protocol_message.has_attach_subscribe_flag?).to eq(true) 20 | expect(protocol_message.has_attach_presence_subscribe_flag?).to eq(true) 21 | 22 | expect(protocol_message.has_attach_resume_flag?).to eq(false) 23 | expect(protocol_message.has_attach_presence_flag?).to eq(false) 24 | end 25 | end 26 | 27 | describe '#set_modes_from_flags' do 28 | let(:subscribe_flag) { 262144 } 29 | 30 | it 'converts flags to ChannelOptions#modes correctly' do 31 | result = options.set_modes_from_flags(subscribe_flag) 32 | 33 | expect(result).to eq(options.modes) 34 | expect(options.modes.map(&:to_sym)).to eq(%i[subscribe]) 35 | end 36 | end 37 | 38 | describe '#set_params' do 39 | let(:previous_params) { { example_attribute: 1 } } 40 | let(:new_params) { { new_attribute: 1 } } 41 | let(:params) { previous_params } 42 | 43 | it 'should be able to overwrite attributes' do 44 | expect { options.set_params(new_params) }.to \ 45 | change { options.params }.from(previous_params).to(new_params) 46 | end 47 | 48 | it 'should be able to make params empty' do # (1) 49 | expect { options.set_params({}) }.to change { options.params }.from(previous_params).to({}) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/unit/models/channel_state_change_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'shared/model_behaviour' 3 | 4 | describe Ably::Models::ChannelStateChange do 5 | let(:unique) { random_str } 6 | 7 | subject { Ably::Models::ChannelStateChange } 8 | 9 | context '#current (#TH1)' do 10 | it 'is required' do 11 | expect { subject.new(previous: true) }.to raise_error ArgumentError 12 | end 13 | 14 | it 'is an attribute' do 15 | expect(subject.new(current: unique, previous: true).current).to eql(unique) 16 | end 17 | end 18 | 19 | context '#previous (#TH2)' do 20 | it 'is required' do 21 | expect { subject.new(current: true) }.to raise_error ArgumentError 22 | end 23 | 24 | it 'is an attribute' do 25 | expect(subject.new(previous: unique, current: true).previous).to eql(unique) 26 | end 27 | end 28 | 29 | context '#event (#TH5)' do 30 | it 'is not required' do 31 | expect { subject.new(previous: true, current: true) }.to_not raise_error 32 | end 33 | 34 | it 'is an attribute' do 35 | expect(subject.new(event: unique, previous: unique, current: true).event).to eql(unique) 36 | end 37 | end 38 | 39 | context '#reason (#TH3)' do 40 | it 'is not required' do 41 | expect { subject.new(previous: true, current: true) }.to_not raise_error 42 | end 43 | 44 | it 'is an attribute' do 45 | expect(subject.new(reason: unique, previous: unique, current: true).reason).to eql(unique) 46 | end 47 | end 48 | 49 | context '#resumed (#TH4)' do 50 | it 'is false when ommitted' do 51 | expect(subject.new(previous: true, current: true).resumed).to be_falsey 52 | end 53 | 54 | it 'is true when provided' do 55 | expect(subject.new(previous: true, current: true, resumed: true).resumed).to be_truthy 56 | end 57 | end 58 | 59 | context 'invalid attributes' do 60 | it 'raises an argument error' do 61 | expect { subject.new(invalid: true, current: true, previous: true) }.to raise_error ArgumentError 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/unit/models/connection_state_change_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'shared/model_behaviour' 3 | 4 | describe Ably::Models::ConnectionStateChange do 5 | let(:unique) { random_str } 6 | 7 | subject { Ably::Models::ConnectionStateChange } 8 | 9 | context '#current (#TA2)' do 10 | it 'is required' do 11 | expect { subject.new(previous: true) }.to raise_error ArgumentError 12 | end 13 | 14 | it 'is an attribute' do 15 | expect(subject.new(current: unique, previous: true).current).to eql(unique) 16 | end 17 | end 18 | 19 | context '#previous(#TA2)' do 20 | it 'is required' do 21 | expect { subject.new(current: true) }.to raise_error ArgumentError 22 | end 23 | 24 | it 'is an attribute' do 25 | expect(subject.new(previous: unique, current: true).previous).to eql(unique) 26 | end 27 | end 28 | 29 | context '#event(#TA5)' do 30 | it 'is not required' do 31 | expect { subject.new(previous: true, current: true) }.to_not raise_error 32 | end 33 | 34 | it 'is an attribute' do 35 | expect(subject.new(event: unique, current: true, previous: true).event).to eql(unique) 36 | end 37 | end 38 | 39 | 40 | context '#retry_in (#TA2)' do 41 | it 'is not required' do 42 | expect { subject.new(previous: true, current: true) }.to_not raise_error 43 | end 44 | 45 | it 'is an attribute' do 46 | expect(subject.new(retry_in: unique, previous: unique, current: true).retry_in).to eql(unique) 47 | end 48 | end 49 | 50 | context '#reason (#TA3)' do 51 | it 'is not required' do 52 | expect { subject.new(previous: true, current: true) }.to_not raise_error 53 | end 54 | 55 | it 'is an attribute' do 56 | expect(subject.new(reason: unique, previous: unique, current: true).reason).to eql(unique) 57 | end 58 | end 59 | 60 | context 'invalid attributes' do 61 | it 'raises an argument error' do 62 | expect { subject.new(invalid: true, current: true, previous: true) }.to raise_error ArgumentError 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/ably/rest/channel/push_channel.rb: -------------------------------------------------------------------------------- 1 | module Ably::Rest 2 | class Channel 3 | # A push channel used for push notifications 4 | # Each PushChannel maps to exactly one Rest Channel 5 | # 6 | class PushChannel 7 | attr_reader :channel 8 | 9 | def initialize(channel) 10 | raise ArgumentError, "Unsupported channel type '#{channel.class}'" unless channel.kind_of?(Ably::Rest::Channel) 11 | @channel = channel 12 | end 13 | 14 | def to_s 15 | "" 16 | end 17 | 18 | # Subscribe local device for push notifications on this channel 19 | # 20 | # @note This is unsupported in the Ruby library 21 | def subscribe_device(*args) 22 | raise_unsupported 23 | end 24 | 25 | # Subscribe all devices registered to this client's authenticated client_id for push notifications on this channel 26 | # 27 | # @note This is unsupported in the Ruby library 28 | def subscribe_client_id(*args) 29 | raise_unsupported 30 | end 31 | 32 | # Unsubscribe local device for push notifications on this channel 33 | # 34 | # @note This is unsupported in the Ruby library 35 | def unsubscribe_device(*args) 36 | raise_unsupported 37 | end 38 | 39 | # Unsubscribe all devices registered to this client's authenticated client_id for push notifications on this channel 40 | # 41 | # @note This is unsupported in the Ruby library 42 | def unsubscribe_client_id(*args) 43 | raise_unsupported 44 | end 45 | 46 | # Get list of subscriptions on this channel for this device or authenticate client_id 47 | # 48 | # @note This is unsupported in the Ruby library 49 | def get_subscriptions(*args) 50 | raise_unsupported 51 | end 52 | 53 | private 54 | def raise_unsupported 55 | raise Ably::Exceptions::PushNotificationsNotSupported, 'This device does not support receiving or subscribing to push notifications. All PushChannel methods are unavailable' 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/ably/realtime/presence/presence_state_machine.rb: -------------------------------------------------------------------------------- 1 | require 'ably/modules/state_machine' 2 | 3 | module Ably::Realtime 4 | class Presence 5 | # Internal class to manage presence state for {Ably::Realtime::Presence} 6 | # 7 | # @api private 8 | # 9 | class PresenceStateMachine 10 | include Ably::Modules::StateMachine 11 | 12 | # States supported by this StateMachine match #{Presence::STATE}s 13 | # :initialized 14 | # :entering 15 | # :entered 16 | # :leaving 17 | # :left 18 | Presence::STATE.each_with_index do |state_enum, index| 19 | state state_enum.to_sym, initial: index == 0 20 | end 21 | 22 | # Entering or entered states can skip leaving and go straight to left if a channel is detached 23 | # A channel that detaches very quickly will also go straight to :left from :initialized 24 | transition :from => :initialized, :to => [:entering, :left] 25 | transition :from => :entering, :to => [:entered, :leaving, :left] 26 | transition :from => :entered, :to => [:leaving, :left] 27 | transition :from => :leaving, :to => [:left, :entering] 28 | 29 | after_transition do |presence, transition| 30 | presence.synchronize_state_with_statemachine 31 | end 32 | 33 | after_transition(to: [:entering]) do |presence, current_transition| 34 | presence.manager.enter current_transition.metadata 35 | end 36 | 37 | after_transition(to: [:leaving]) do |presence, current_transition| 38 | presence.manager.leave current_transition.metadata 39 | end 40 | 41 | # Transitions responsible for updating channel#error_reason 42 | before_transition(to: [:left]) do |presence, current_transition| 43 | presence.channel.set_channel_error_reason current_transition.metadata if is_error_type?(current_transition.metadata) 44 | end 45 | 46 | private 47 | def channel 48 | object.channel 49 | end 50 | 51 | # Logged needs to be defined as it is used by {Ably::Modules::StateMachine} 52 | def logger 53 | channel.logger 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/ably/rest/middleware/exceptions.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | require 'json' 3 | 4 | module Ably 5 | module Rest 6 | module Middleware 7 | # HTTP exceptions raised by Ably due to an error status code 8 | # Ably returns JSON/Msgpack error codes and messages so include this if possible in the exception messages 9 | class Exceptions < Faraday::Middleware 10 | def on_complete(env) 11 | if env.status >= 400 12 | error_status_code = env.status 13 | error_code = nil 14 | 15 | if env.body.kind_of?(Hash) 16 | error = env.body.fetch('error', {}) 17 | error_status_code = error['statusCode'].to_i if error['statusCode'] 18 | error_code = error['code'].to_i if error['code'] 19 | 20 | if error 21 | message = "#{error['message']} (status: #{error_status_code}, code: #{error_code})" 22 | else 23 | message = env.body 24 | end 25 | else 26 | message = env.body 27 | end 28 | 29 | message = 'Unknown server error' if message.to_s.strip == '' 30 | request_id = env.request.context[:request_id] if env.request.context 31 | exception_args = [message, error_status_code, error_code, nil, { request_id: request_id }] 32 | 33 | if env.status >= 500 34 | raise Ably::Exceptions::ServerError.new(*exception_args) 35 | elsif env.status == 401 36 | if Ably::Exceptions::TOKEN_EXPIRED_CODE.include?(error_code) 37 | raise Ably::Exceptions::TokenExpired.new(*exception_args) 38 | else 39 | raise Ably::Exceptions::UnauthorizedRequest.new(*exception_args) 40 | end 41 | elsif env.status == 403 42 | raise Ably::Exceptions::ForbiddenRequest.new(*exception_args) 43 | elsif env.status == 404 44 | raise Ably::Exceptions::ResourceMissing.new(*exception_args) 45 | else 46 | raise Ably::Exceptions::InvalidRequest.new(*exception_args) 47 | end 48 | end 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | check: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | ruby: [ '2.7', '3.0', '3.1', '3.2', '3.3' ] 15 | protocol: [ 'json', 'msgpack' ] 16 | type: [ 'unit', 'acceptance' ] 17 | steps: 18 | - uses: actions/checkout@v2 19 | with: 20 | submodules: 'recursive' 21 | - uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: ${{ matrix.ruby }} 24 | bundler-cache: true 25 | - name: 'Run ${{ matrix.type }} tests on ruby ${{ matrix.ruby }} (${{ matrix.protocol }} protocol)' 26 | env: 27 | PARALLEL_TEST_PROCESSORS: 2 28 | RSPEC_RETRY: true 29 | PROTOCOL: ${{ matrix.protocol }} 30 | TEST_TYPE: ${{ matrix.type }} 31 | RUBY_VERSION: ${{ matrix.ruby }} 32 | run: | 33 | mkdir junit 34 | bundle exec parallel_rspec --prefix-output-with-test-env-number --first-is-1 -- spec/${{ matrix.type }} 35 | - uses: actions/upload-artifact@v4 36 | with: 37 | name: test-results-ruby-${{ matrix.ruby }}-${{ matrix.protocol }}-${{ matrix.type }} 38 | path: | 39 | junit/ 40 | coverage/ 41 | retention-days: 7 42 | - name: Upload test results 43 | if: always() 44 | uses: ably/test-observability-action@v1 45 | with: 46 | server-url: 'https://test-observability.herokuapp.com' 47 | server-auth: ${{ secrets.TEST_OBSERVABILITY_SERVER_AUTH_KEY }} 48 | path: 'junit/' 49 | - uses: coverallsapp/github-action@v2 50 | with: 51 | github-token: ${{ secrets.GITHUB_TOKEN }} 52 | flag-name: ruby-${{ matrix.ruby }}-${{ matrix.protocol }}-${{ matrix.type }} 53 | parallel: true 54 | finish: 55 | needs: check 56 | runs-on: ubuntu-latest 57 | steps: 58 | - name: Coveralls Finished 59 | uses: coverallsapp/github-action@v2 60 | with: 61 | github-token: ${{ secrets.github_token }} 62 | parallel-finished: true 63 | -------------------------------------------------------------------------------- /spec/rspec_config.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # Require this file using `require "spec_helper"` to ensure that it is only 4 | # loaded once. 5 | # 6 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 7 | 8 | require 'rspec/retry' 9 | 10 | RSpec.configure do |config| 11 | config.run_all_when_everything_filtered = true 12 | config.filter_run :focus 13 | 14 | config.mock_with :rspec do |mocks| 15 | # This option should be set when all dependencies are being loaded 16 | # before a spec run, as is the case in a typical spec helper. It will 17 | # cause any verifying double instantiation for a class that does not 18 | # exist to raise, protecting against incorrectly spelt names. 19 | mocks.verify_doubled_constant_names = true 20 | end 21 | 22 | # Run specs in random order to surface order dependencies. If you find an 23 | # order dependency and want to debug it, you can fix the order by providing 24 | # the seed, which is printed after each run. 25 | # --seed 1234 26 | config.order = 'random' 27 | 28 | config.before(:example) do 29 | WebMock.disable! 30 | end 31 | 32 | config.before(:example, :webmock) do 33 | allow(TestApp).to receive(:instance).and_return(instance_double('TestApp', 34 | app_id: 'app_id', 35 | key_name: 'app_id.key_name', 36 | key_secret: 'secret', 37 | api_key: 'app_id.key_name:secret', 38 | environment: 'sandbox' 39 | )) 40 | WebMock.enable! 41 | end 42 | 43 | if defined?(EventMachine) 44 | config.before(:example, :event_machine) do 45 | # Ensure EventMachine shutdown hooks are deregistered for every test 46 | EventMachine.instance_variable_set '@tails', [] 47 | end 48 | end 49 | 50 | config.add_formatter Ably::RSpec::PrivateApiFormatter 51 | 52 | if ENV['RSPEC_RETRY'] 53 | puts 'Running tests using RSpec retry' 54 | config.verbose_retry = true # show retry status in spec process 55 | config.display_try_failure_messages = true # show exception that triggered the try 56 | config.default_retry_count = 3 57 | config.default_sleep_interval = 2 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/unit/realtime/client_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | require 'shared/client_initializer_behaviour' 4 | 5 | describe Ably::Realtime::Client do 6 | subject(:realtime_client) do 7 | Ably::Realtime::Client.new(client_options) 8 | end 9 | 10 | it_behaves_like 'a client initializer' 11 | 12 | context 'delegation to the REST Client' do 13 | let(:client_options) { { key: 'appid.keyuid:keysecret', auto_connect: false } } 14 | 15 | it 'passes on the options to the initializer' do 16 | rest_client = instance_double('Ably::Rest::Client', auth: instance_double('Ably::Auth'), options: client_options, environment: 'production', use_tls?: true, custom_tls_port: nil) 17 | expect(Ably::Rest::Client).to receive(:new).with(hash_including(client_options)).and_return(rest_client) 18 | realtime_client 19 | end 20 | 21 | context 'for attribute' do 22 | [:environment, :use_tls?, :log_level, :custom_host].each do |attribute| 23 | specify "##{attribute}" do 24 | expect(realtime_client.rest_client).to receive(attribute) 25 | realtime_client.public_send attribute 26 | end 27 | end 28 | end 29 | end 30 | 31 | context 'when :transport_params option is passed' do 32 | let(:expected_transport_params) do 33 | { 'heartbeats' => 'true', 'v' => '1.0', 'extra_param' => 'extra_param' } 34 | end 35 | let(:client_options) do 36 | { key: 'appid.keyuid:keysecret', transport_params: { heartbeats: true, v: 1.0, extra_param: 'extra_param'} } 37 | end 38 | 39 | it 'converts options to strings' do 40 | expect(realtime_client.transport_params).to eq(expected_transport_params) 41 | end 42 | end 43 | 44 | context 'push' do 45 | let(:client_options) { { key: 'appid.keyuid:keysecret' } } 46 | 47 | specify '#device is not supported and raises an exception' do 48 | expect { realtime_client.device }.to raise_error Ably::Exceptions::PushNotificationsNotSupported 49 | end 50 | 51 | specify '#push returns a Push object' do 52 | expect(realtime_client.push).to be_a(Ably::Realtime::Push) 53 | end 54 | end 55 | 56 | after(:all) do 57 | sleep 1 # let realtime library shut down any open clients 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | require 'bundler/gem_tasks' 4 | require 'json' 5 | 6 | require 'yard' 7 | YARD::Rake::YardocTask.new 8 | 9 | begin 10 | require 'rspec/core/rake_task' 11 | 12 | rspec_task = RSpec::Core::RakeTask.new(:spec) 13 | 14 | task :default => :spec 15 | 16 | namespace :doc do 17 | desc 'Generate Markdown Specification from the RSpec public API tests' 18 | task :spec do 19 | ENV['PROTOCOL'] = 'json' 20 | 21 | rspec_task.rspec_opts = %w( 22 | --require ./spec/support/markdown_spec_formatter 23 | --order defined 24 | --tag ~api_private 25 | --format documentation 26 | --format Ably::RSpec::MarkdownSpecFormatter 27 | ).join(' ') 28 | 29 | Rake::Task[:spec].invoke 30 | end 31 | end 32 | 33 | desc 'Generate error code constants from ably-common: https://github.com/ably/ably-common/issues/32' 34 | task :generate_error_codes do 35 | errors_json_path = File.join(File.dirname(__FILE__), 'lib/submodules/ably-common/protocol/errors.json') 36 | module_path = File.join(File.dirname(__FILE__), 'lib/ably/modules/exception_codes.rb') 37 | max_length = 0 38 | 39 | errors = JSON.parse(File.read(errors_json_path)).each_with_object({}) do |(key, val), hash| 40 | hash[key] = val.split(/\s+/).map { |d| d.upcase.gsub(/[^a-zA-Z]+/, '') }.join('_') 41 | end.each do |code, const_name| 42 | max_length = [const_name.length, max_length].max 43 | end.map do |code, const_name| 44 | " #{const_name.ljust(max_length, ' ')} = #{code}" 45 | end.join("\n") 46 | module_content = <<-EOF 47 | # This file is generated by running `rake :generate_error_codes` 48 | # Do not manually modify this file 49 | # Generated at: #{Time.now.utc} 50 | # 51 | module Ably 52 | module Exceptions 53 | module Codes 54 | #{errors} 55 | end 56 | end 57 | end 58 | EOF 59 | File.open(module_path, 'w') { |file| file.write module_content } 60 | 61 | puts "Error code constants have been generated into #{module_path}" 62 | puts "Warning: Search for any constants referenced in this library if their name has changed as a result of this constant generation!" 63 | end 64 | rescue LoadError 65 | # RSpec not available 66 | end 67 | -------------------------------------------------------------------------------- /spec/unit/modules/conversions_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ably::Modules::Conversions, :api_private do 4 | let(:class_with_module) { Class.new do; include Ably::Modules::Conversions; end } 5 | let(:subject) { class_with_module.new } 6 | before do 7 | # make method being tested public 8 | class_with_module.class_eval %{ public :#{method} } 9 | end 10 | 11 | context '#as_since_epoch' do 12 | let(:method) { :as_since_epoch } 13 | 14 | context 'with time' do 15 | let(:time) { Time.new } 16 | 17 | it 'converts to milliseconds by default' do 18 | expect(subject.as_since_epoch(time)).to be_within(1).of(time.to_f * 1_000) 19 | end 20 | 21 | it 'converted to seconds' do 22 | expect(subject.as_since_epoch(time, granularity: :s)).to eql(time.to_i) 23 | end 24 | end 25 | 26 | context 'with numeric' do 27 | it 'converts to integer' do 28 | expect(subject.as_since_epoch(1.01)).to eql(1) 29 | end 30 | 31 | it 'accepts integers' do 32 | expect(subject.as_since_epoch(1)).to eql(1) 33 | end 34 | end 35 | 36 | context 'with any other object' do 37 | it 'raises an exception' do 38 | expect { subject.as_since_epoch(Object.new) }.to raise_error ArgumentError 39 | end 40 | end 41 | end 42 | 43 | context '#as_time_from_epoch' do 44 | let(:method) { :as_time_from_epoch } 45 | let(:time) { Time.new } 46 | 47 | context 'with numeric' do 48 | let(:millisecond) { Time.new.to_f * 1_000 } 49 | let(:seconds) { Time.new.to_f } 50 | 51 | it 'converts to Time from milliseconds by default' do 52 | expect(subject.as_time_from_epoch(millisecond).to_f).to be_within(0.01).of(time.to_f) 53 | end 54 | 55 | it 'converts to Time from seconds' do 56 | expect(subject.as_time_from_epoch(seconds, granularity: :s).to_i).to eql(time.to_i) 57 | end 58 | end 59 | 60 | context 'with Time' do 61 | it 'leaves intact' do 62 | expect(subject.as_time_from_epoch(time)).to eql(time) 63 | end 64 | end 65 | 66 | context 'with any other object' do 67 | it 'raises an exception' do 68 | expect { subject.as_time_from_epoch(Object.new) }.to raise_error ArgumentError 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/unit/auth_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'shared/protocol_msgbus_behaviour' 3 | 4 | describe Ably::Auth do 5 | let(:client) { double('client').as_null_object } 6 | let(:client_id) { nil } 7 | let(:auth_options) { { key: 'appid.keyuid:keysecret', client_id: client_id } } 8 | let(:token_params) { { } } 9 | 10 | subject do 11 | Ably::Auth.new(client, token_params, auth_options) 12 | end 13 | 14 | describe 'client_id option' do 15 | let(:client_id) { random_str.encode(encoding) } 16 | 17 | context 'with nil value' do 18 | let(:client_id) { nil } 19 | 20 | it 'is permitted' do 21 | expect(subject.client_id).to be_nil 22 | end 23 | end 24 | 25 | context 'as UTF_8 string' do 26 | let(:encoding) { Encoding::UTF_8 } 27 | 28 | it 'is permitted' do 29 | expect(subject.client_id).to eql(client_id) 30 | end 31 | 32 | it 'remains as UTF-8' do 33 | expect(subject.client_id.encoding).to eql(encoding) 34 | end 35 | end 36 | 37 | context 'as SHIFT_JIS string' do 38 | let(:encoding) { Encoding::SHIFT_JIS } 39 | 40 | it 'gets converted to UTF-8' do 41 | expect(subject.client_id.encoding).to eql(Encoding::UTF_8) 42 | end 43 | 44 | it 'is compatible with original encoding' do 45 | expect(subject.client_id.encode(encoding)).to eql(client_id) 46 | end 47 | end 48 | 49 | context 'as ASCII_8BIT string' do 50 | let(:encoding) { Encoding::ASCII_8BIT } 51 | 52 | it 'gets converted to UTF-8' do 53 | expect(subject.client_id.encoding).to eql(Encoding::UTF_8) 54 | end 55 | 56 | it 'is compatible with original encoding' do 57 | expect(subject.client_id.encode(encoding)).to eql(client_id) 58 | end 59 | end 60 | 61 | context 'as Integer' do 62 | let(:client_id) { 1 } 63 | 64 | it 'raises an argument error' do 65 | expect { subject.client_id }.to raise_error ArgumentError, /must be a String/ 66 | end 67 | end 68 | end 69 | 70 | context 'defaults' do 71 | it 'should have no default TTL' do 72 | expect(Ably::Auth::TOKEN_DEFAULTS[:ttl]).to be_nil 73 | end 74 | 75 | it 'should have no default capability' do 76 | expect(Ably::Auth::TOKEN_DEFAULTS[:capability]).to be_nil 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/ably/rest/push/admin.rb: -------------------------------------------------------------------------------- 1 | require 'ably/rest/push/device_registrations' 2 | require 'ably/rest/push/channel_subscriptions' 3 | 4 | module Ably::Rest 5 | class Push 6 | # Class providing push notification administrative functionality 7 | # for registering devices and attaching to channels etc. 8 | class Admin 9 | include Ably::Modules::Conversions 10 | 11 | # @api private 12 | attr_reader :client 13 | 14 | # @api private 15 | attr_reader :push 16 | 17 | def initialize(push) 18 | @push = push 19 | @client = push.client 20 | end 21 | 22 | # Publish a push message directly to a single recipient 23 | # 24 | # @param recipient [Hash] A recipient device, client_id or raw APNS/FCM/web target. Refer to push documentation 25 | # @param data [Hash] The notification payload data and fields. Refer to push documentation 26 | # 27 | # @return [void] 28 | # 29 | def publish(recipient, data) 30 | raise ArgumentError, "Expecting a Hash object for recipient, got #{recipient.class}" unless recipient.kind_of?(Hash) 31 | raise ArgumentError, "Recipient data is empty. You must provide recipient details" if recipient.empty? 32 | raise ArgumentError, "Expecting a Hash object for data, got #{data.class}" unless data.kind_of?(Hash) 33 | raise ArgumentError, "Push data field is empty. You must provide attributes for the push notification" if data.empty? 34 | 35 | publish_data = data.merge(recipient: IdiomaticRubyWrapper(recipient)) 36 | # Co-erce to camelCase for notitication fields which are always camelCase 37 | publish_data[:notification] = IdiomaticRubyWrapper(data[:notification]) if publish_data[:notification].kind_of?(Hash) 38 | client.post('/push/publish', publish_data) 39 | end 40 | 41 | # Manage device registrations 42 | # 43 | # @return [Ably::Rest::Push::DeviceRegistrations] 44 | # 45 | def device_registrations 46 | @device_registrations ||= DeviceRegistrations.new(self) 47 | end 48 | 49 | # Manage channel subscriptions for devices or clients 50 | # 51 | # @return [Ably::Rest::Push::ChannelSubscriptions] 52 | # 53 | def channel_subscriptions 54 | @channel_subscriptions ||= ChannelSubscriptions.new(self) 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/ably/realtime/channel/push_channel.rb: -------------------------------------------------------------------------------- 1 | module Ably::Realtime 2 | class Channel 3 | # Enables devices to subscribe to push notifications for a channel. 4 | # 5 | class PushChannel 6 | attr_reader :channel 7 | 8 | def initialize(channel) 9 | raise ArgumentError, "Unsupported channel type '#{channel.class}'" unless channel.kind_of?(Ably::Realtime::Channel) 10 | @channel = channel 11 | end 12 | 13 | def to_s 14 | "" 15 | end 16 | 17 | # Subscribes the device to push notifications for the channel. 18 | # 19 | # @spec RSH7a 20 | # 21 | # @note This is unsupported in the Ruby library 22 | def subscribe_device(*args) 23 | raise_unsupported 24 | end 25 | 26 | # Subscribes all devices associated with the current device's clientId to push notifications for the channel. 27 | # 28 | # @spec RSH7b 29 | # 30 | # @note This is unsupported in the Ruby library 31 | def subscribe_client_id(*args) 32 | raise_unsupported 33 | end 34 | 35 | # Unsubscribes the device from receiving push notifications for the channel. 36 | # 37 | # @spec RSH7c 38 | # 39 | # @note This is unsupported in the Ruby library 40 | def unsubscribe_device(*args) 41 | raise_unsupported 42 | end 43 | 44 | # Unsubscribes all devices associated with the current device's clientId from receiving push notifications for the channel. 45 | # 46 | # @spec RSH7d 47 | # 48 | # @note This is unsupported in the Ruby library 49 | def unsubscribe_client_id(*args) 50 | raise_unsupported 51 | end 52 | 53 | # Retrieves all push subscriptions for the channel. Subscriptions can be filtered using a params object. 54 | # Returns a {Ably::Models::PaginatedResult} object containing an array of {Ably::Models::PushChannelSubscription} objects. 55 | # 56 | # @spec RSH7e 57 | # 58 | # @note This is unsupported in the Ruby library 59 | def get_subscriptions(*args) 60 | raise_unsupported 61 | end 62 | 63 | private 64 | def raise_unsupported 65 | raise Ably::Exceptions::PushNotificationsNotSupported, 'This device does not support receiving or subscribing to push notifications. All PushChannel methods are unavailable' 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/acceptance/realtime/presence_history_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe Ably::Realtime::Presence, 'history', :event_machine do 5 | vary_by_protocol do 6 | let(:default_options) { { key: api_key, environment: environment, protocol: protocol } } 7 | 8 | let(:channel_name) { "persisted:#{random_str(2)}" } 9 | 10 | let(:client_one) { auto_close Ably::Realtime::Client.new(default_options.merge(client_id: random_str)) } 11 | let(:channel_client_one) { client_one.channel(channel_name) } 12 | let(:presence_client_one) { channel_client_one.presence } 13 | 14 | let(:client_two) { auto_close Ably::Realtime::Client.new(default_options.merge(client_id: random_str)) } 15 | let(:channel_client_two) { client_two.channel(channel_name) } 16 | let(:presence_client_two) { channel_client_two.presence } 17 | 18 | let(:data) { random_str } 19 | let(:leave_data) { random_str } 20 | 21 | it 'provides up to the moment presence history' do 22 | presence_client_one.enter(data) do 23 | presence_client_one.subscribe(:leave) do 24 | presence_client_one.history do |history_page| 25 | expect(history_page).to be_a(Ably::Models::PaginatedResult) 26 | expect(history_page.items.count).to eql(2) 27 | 28 | expect(history_page.items[1].action).to eq(:enter) 29 | expect(history_page.items[1].client_id).to eq(client_one.client_id) 30 | expect(history_page.items[1].data).to eql(data) 31 | 32 | expect(history_page.items[0].action).to eq(:leave) 33 | expect(history_page.items[0].client_id).to eq(client_one.client_id) 34 | expect(history_page.items[0].data).to eql(leave_data) 35 | 36 | stop_reactor 37 | end 38 | end 39 | 40 | presence_client_one.leave(leave_data) 41 | end 42 | end 43 | 44 | it 'ensures REST presence history message IDs match ProtocolMessage wrapped message and connection IDs via Realtime' do 45 | presence_client_one.subscribe(:enter) do |message| 46 | presence_client_one.history do |history_page| 47 | expect(history_page.items.count).to eql(1) 48 | 49 | expect(history_page.items[0].id).to eql(message.id) 50 | expect(history_page.items[0].connection_id).to eql(message.connection_id) 51 | stop_reactor 52 | end 53 | end 54 | 55 | presence_client_one.enter(data) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/ably/realtime/push/admin.rb: -------------------------------------------------------------------------------- 1 | require 'ably/realtime/push/device_registrations' 2 | require 'ably/realtime/push/channel_subscriptions' 3 | 4 | module Ably::Realtime 5 | class Push 6 | # Class providing push notification administrative functionality 7 | # for registering devices and attaching to channels etc. 8 | # 9 | class Admin 10 | include Ably::Modules::AsyncWrapper 11 | include Ably::Modules::Conversions 12 | 13 | # @api private 14 | attr_reader :client 15 | 16 | # @api private 17 | attr_reader :push 18 | 19 | def initialize(push) 20 | @push = push 21 | @client = push.client 22 | end 23 | 24 | # Sends a push notification directly to a device, or a group of devices sharing the same clientId. 25 | # 26 | # (see Ably::Rest::Push#publish) 27 | # 28 | # @spec RSH1a 29 | # 30 | # @yield Block is invoked upon successful publish of the message 31 | # 32 | # @return [Ably::Util::SafeDeferrable] 33 | # 34 | def publish(recipient, data, &callback) 35 | raise ArgumentError, "Expecting a Hash object for recipient, got #{recipient.class}" unless recipient.kind_of?(Hash) 36 | raise ArgumentError, "Recipient data is empty. You must provide recipient details" if recipient.empty? 37 | raise ArgumentError, "Expecting a Hash object for data, got #{data.class}" unless data.kind_of?(Hash) 38 | raise ArgumentError, "Push data field is empty. You must provide attributes for the push notification" if data.empty? 39 | 40 | async_wrap(callback) do 41 | rest_push_admin.publish(recipient, data) 42 | end 43 | end 44 | 45 | # A {Ably::Realtime::Push::DeviceRegistrations} object. 46 | # 47 | # @spec RSH1b 48 | # 49 | # @return [Ably::Realtime::Push::DeviceRegistrations] 50 | # 51 | def device_registrations 52 | @device_registrations ||= DeviceRegistrations.new(self) 53 | end 54 | 55 | # A {Ably::Realtime::Push::ChannelSubscriptions} object. 56 | # 57 | # @spec RSH1c 58 | # 59 | # @return [Ably::Realtime::Push::ChannelSubscriptions] 60 | # 61 | def channel_subscriptions 62 | @channel_subscriptions ||= ChannelSubscriptions.new(self) 63 | end 64 | 65 | private 66 | def rest_push_admin 67 | client.rest_client.push.admin 68 | end 69 | 70 | def logger 71 | client.logger 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/ably/models/connection_state_change.rb: -------------------------------------------------------------------------------- 1 | module Ably::Models 2 | # Contains {Ably::Models::ConnectionState} change information emitted by the {Ably::Realtime::Connection} object. 3 | # 4 | class ConnectionStateChange 5 | include Ably::Modules::ModelCommon 6 | 7 | def initialize(hash_object) 8 | unless (hash_object.keys - [:current, :previous, :event, :retry_in, :reason, :protocol_message]).empty? 9 | raise ArgumentError, 'Invalid attributes, expecting :current, :previous, :event, :retry_in, :reason' 10 | end 11 | 12 | @hash_object = { 13 | current: hash_object.fetch(:current), 14 | previous: hash_object.fetch(:previous), 15 | event: hash_object[:event], 16 | retry_in: hash_object[:retry_in], 17 | reason: hash_object[:reason], 18 | protocol_message: hash_object[:protocol_message] 19 | } 20 | rescue KeyError => e 21 | raise ArgumentError, e 22 | end 23 | 24 | # The new {Ably::Realtime::Connection::STATE}. 25 | # 26 | # @spec TA2 27 | # 28 | # @return [Ably::Realtime::Connection::STATE] 29 | # 30 | def current 31 | @hash_object[:current] 32 | end 33 | 34 | # The event that triggered this {Ably::Realtime::Connection::EVENT} change. 35 | # 36 | # @spec TA5 37 | # 38 | # @return [Ably::Realtime::Connection::STATE] 39 | # 40 | def event 41 | @hash_object[:event] 42 | end 43 | 44 | # The previous {Ably::Models::Connection::STATE}. For the {Ably::Models::Connection::EVENT} UPDATE event, 45 | # this is equal to the current {Ably::Models::Connection::STATE}. 46 | # 47 | # @spec TA2 48 | # 49 | # @return [Ably::Realtime::Connection::STATE] 50 | # 51 | def previous 52 | @hash_object[:previous] 53 | end 54 | 55 | # An {Ably::Models::ErrorInfo} object containing any information relating to the transition. 56 | # 57 | # @spec RTN4f, TA3 58 | # 59 | # @return [Ably::Models::ErrorInfo, nil] 60 | # 61 | def reason 62 | @hash_object[:reason] 63 | end 64 | 65 | # Duration in milliseconds, after which the client retries a connection where applicable. 66 | # 67 | # @spec RTN14d, TA2 68 | # 69 | # @return [Integer] 70 | # 71 | def retry_in 72 | @hash_object[:retry_in] 73 | end 74 | 75 | def protocol_message 76 | @hash_object[:protocol_message] 77 | end 78 | 79 | def to_s 80 | "" 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/shared/safe_deferrable_behaviour.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | shared_examples 'a safe Deferrable' do 4 | let(:logger) { instance_double('Logger') } 5 | let(:arguments) { [random_str] } 6 | let(:errback_calls) { [] } 7 | let(:success_calls) { [] } 8 | let(:exception) { StandardError.new("Intentional error") } 9 | 10 | before do 11 | allow(subject).to receive(:logger).and_return(logger) 12 | end 13 | 14 | context '#errback' do 15 | it 'adds a callback that is called when #fail is called' do 16 | subject.errback do |*args| 17 | expect(args).to eql(arguments) 18 | end 19 | subject.fail(*arguments) 20 | end 21 | 22 | it 'catches exceptions in the callback and logs the error to the logger' do 23 | expect(subject.send(:logger)).to receive(:error) do |*args, &block| 24 | expect(args.concat([block ? block.call : nil]).join(',')).to match(/#{exception.message}/) 25 | end 26 | subject.errback do 27 | raise exception 28 | end 29 | subject.fail 30 | end 31 | end 32 | 33 | context '#fail' do 34 | it 'calls the callbacks defined with #errback, but not the ones added for success #callback' do 35 | 3.times do 36 | subject.errback { errback_calls << true } 37 | subject.callback { success_calls << true } 38 | end 39 | subject.fail(*arguments) 40 | expect(errback_calls.count).to eql(3) 41 | expect(success_calls.count).to eql(0) 42 | end 43 | end 44 | 45 | context '#callback' do 46 | it 'adds a callback that is called when #succed is called' do 47 | subject.callback do |*args| 48 | expect(args).to eql(arguments) 49 | end 50 | subject.succeed(*arguments) 51 | end 52 | 53 | it 'catches exceptions in the callback and logs the error to the logger' do 54 | expect(subject.send(:logger)).to receive(:error) do |*args, &block| 55 | expect(args.concat([block ? block.call : nil]).join(',')).to match(/#{exception.message}/) 56 | end 57 | subject.callback do 58 | raise exception 59 | end 60 | subject.succeed 61 | end 62 | end 63 | 64 | context '#succeed' do 65 | it 'calls the callbacks defined with #callback, but not the ones added for #errback' do 66 | 3.times do 67 | subject.errback { errback_calls << true } 68 | subject.callback { success_calls << true } 69 | end 70 | subject.succeed(*arguments) 71 | expect(success_calls.count).to eql(3) 72 | expect(errback_calls.count).to eql(0) 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/ably/models/channel_state_change.rb: -------------------------------------------------------------------------------- 1 | module Ably::Models 2 | # Contains state change information emitted by {Ably::Rest::Channel} and {Ably::Realtime::Channel} objects. 3 | # 4 | class ChannelStateChange 5 | include Ably::Modules::ModelCommon 6 | 7 | def initialize(hash_object) 8 | unless (hash_object.keys - [:current, :previous, :event, :reason, :resumed, :protocol_message]).empty? 9 | raise ArgumentError, 'Invalid attributes, expecting :current, :previous, :event, :reason, :resumed' 10 | end 11 | 12 | @hash_object = { 13 | current: hash_object.fetch(:current), 14 | previous: hash_object.fetch(:previous), 15 | event: hash_object[:event], 16 | reason: hash_object[:reason], 17 | protocol_message: hash_object[:protocol_message], 18 | resumed: hash_object[:resumed] 19 | } 20 | rescue KeyError => e 21 | raise ArgumentError, e 22 | end 23 | 24 | # The new current {Ably::Realtime::Channel::STATE}. 25 | # 26 | # @spec RTL2a, RTL2b 27 | # 28 | # @return [Ably::Realtime::Channel::STATE] 29 | # 30 | def current 31 | @hash_object[:current] 32 | end 33 | 34 | # The previous state. For the {Ably::Realtime::Channel::EVENT}(:update) event, this is equal to the current {Ably::Realtime::Channel::STATE}. 35 | # 36 | # @spec RTL2a, RTL2b 37 | # 38 | # @return [Ably::Realtime::Channel::EVENT] 39 | # 40 | def previous 41 | @hash_object[:previous] 42 | end 43 | 44 | # The event that triggered this {Ably::Realtime::Channel::STATE} change. 45 | # 46 | # @spec TH5 47 | # 48 | # @return [Ably::Realtime::Channel::STATE] 49 | # 50 | def event 51 | @hash_object[:event] 52 | end 53 | 54 | # An {Ably::Models::ErrorInfo} object containing any information relating to the transition. 55 | # 56 | # @spec RTL2e, TH3 57 | # 58 | # @return [Ably::Models::ErrorInfo, nil] 59 | # 60 | def reason 61 | @hash_object[:reason] 62 | end 63 | 64 | # Indicates whether message continuity on this channel is preserved, see Nonfatal channel errors for more info. 65 | # 66 | # @spec RTL2f, TH4 67 | # 68 | # @return [Boolean] 69 | # 70 | def resumed 71 | !!@hash_object[:resumed] 72 | end 73 | alias_method :resumed?, :resumed 74 | 75 | # @api private 76 | def protocol_message 77 | @hash_object[:protocol_message] 78 | end 79 | 80 | def to_s 81 | "" 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/ably/modules/async_wrapper.rb: -------------------------------------------------------------------------------- 1 | require 'eventmachine' 2 | 3 | module Ably::Modules 4 | # Provides methods to convert synchronous operations into async operations through the use of 5 | # {http://www.rubydoc.info/github/eventmachine/eventmachine/EventMachine#defer-class_method EventMachine#defer}. 6 | # The async_wrap method can only be called from within an EventMachine reactor, and must be thread safe. 7 | # 8 | # @note using this AsyncWrapper should only be used for methods that are used less frequently and typically 9 | # not run with levels of concurrency due to the limited number of threads available to EventMachine by default. 10 | # This module requires that the method #logger is defined. 11 | # 12 | # @example 13 | # class BlockingOperation 14 | # include Aby::Modules::AsyncWrapper 15 | # 16 | # def operation(&success_callback) 17 | # async_wrap(success_callback) do 18 | # sleep 1 19 | # 'slept' 20 | # end 21 | # end 22 | # end 23 | # 24 | # blocking_object = BlockingOperation.new 25 | # deferrable = blocking_object.operation do |result| 26 | # puts "Done with result: #{result}" 27 | # end 28 | # puts "Starting" 29 | # 30 | # # => 'Starting' 31 | # # => 'Done with result: slept' 32 | # 33 | module AsyncWrapper 34 | private 35 | 36 | # Will yield the provided block in a new thread and return an {Ably::Util::SafeDeferrable} 37 | # 38 | # @yield [Object] operation block that is run in a thread 39 | # 40 | # @return [Ably::Util::SafeDeferrable] 41 | # 42 | def async_wrap(success_callback = nil, custom_error_handling = nil) 43 | raise ArgumentError, 'Block required' unless block_given? 44 | 45 | Ably::Util::SafeDeferrable.new(logger).tap do |deferrable| 46 | deferrable.callback(&success_callback) if success_callback 47 | 48 | operation_with_exception_handling = lambda do 49 | begin 50 | yield 51 | rescue StandardError => err 52 | if custom_error_handling 53 | custom_error_handling.call err, deferrable 54 | else 55 | logger.error { "An exception in an AsyncWrapper block was caught. #{err.class}: #{err.message}\n#{err.backtrace.join("\n")}" } 56 | deferrable.fail err 57 | end 58 | end 59 | end 60 | 61 | complete_callback = lambda do |result| 62 | deferrable.succeed result 63 | end 64 | 65 | EventMachine.defer operation_with_exception_handling, complete_callback 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/ably/modules/model_common.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | require 'ably/modules/conversions' 3 | require 'ably/modules/message_pack' 4 | 5 | module Ably::Modules 6 | # Common model functionality shared across many {Ably::Models} 7 | module ModelCommon 8 | include Conversions 9 | include MessagePack 10 | 11 | def self.included(base) 12 | base.extend(ClassMethods) 13 | end 14 | 15 | # Provide a normal Hash accessor to the underlying raw message object 16 | # 17 | # @return [Object] 18 | # 19 | def [](key) 20 | attributes[key] 21 | end 22 | 23 | def ==(other) 24 | other.kind_of?(self.class) && 25 | attributes == other.attributes 26 | end 27 | 28 | # Return a JSON ready object from the underlying #attributes using Ably naming conventions for keys 29 | # 30 | # @return [Hash] 31 | # 32 | def as_json(*args) 33 | attributes.as_json.reject { |key, val| val.nil? } 34 | end 35 | 36 | # Stringify the JSON representation of this object from the underlying #attributes 37 | # 38 | # @return [String] 39 | # 40 | def to_json(*args) 41 | as_json.to_json(*args) 42 | end 43 | 44 | # @!attribute [r] hash 45 | # @return [Integer] Compute a hash-code for this hash. Two hashes with the same content will have the same hash code 46 | def hash 47 | attributes.hash 48 | end 49 | 50 | def to_s 51 | representation = attributes.map do |key, val| 52 | if val.nil? 53 | nil 54 | else 55 | val_str = val.to_s 56 | val_str = "#{val_str[0...80]}..." if val_str.length > 80 57 | "#{key}=#{val_str}" 58 | end 59 | end 60 | "<#{self.class.name}: #{representation.compact.join(', ')}>" 61 | end 62 | 63 | module ClassMethods 64 | # Return a new instance of this object using the provided JSON-like object or JSON string 65 | # @param json_like_object [Hash, String] JSON-like object or JSON string 66 | # @return a new instance to this object 67 | def from_json(json_like_object) 68 | if json_like_object.kind_of?(String) 69 | new(JSON.parse(json_like_object)) 70 | else 71 | new(json_like_object) 72 | end 73 | end 74 | end 75 | 76 | private 77 | def ensure_utf8_string_for(attribute, value) 78 | if value 79 | raise ArgumentError, "#{attribute} must be a String" unless value.kind_of?(String) 80 | raise ArgumentError, "#{attribute} cannot use ASCII_8BIT encoding, please use UTF_8 encoding" unless value.encoding == Encoding::UTF_8 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/unit/models/connection_details_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'shared/model_behaviour' 3 | 4 | describe Ably::Models::ConnectionDetails do 5 | include Ably::Modules::Conversions 6 | 7 | subject { Ably::Models::ConnectionDetails } 8 | 9 | # Spec model items CD2* 10 | it_behaves_like 'a model', with_simple_attributes: %w(client_id connection_key max_message_size max_frame_size max_inbound_rate) do 11 | let(:model_args) { [] } 12 | end 13 | 14 | context 'attributes' do 15 | let(:connection_state_ttl_ms) { 5_000 } 16 | 17 | context '#connection_state_ttl (#CD2f)' do 18 | subject { Ably::Models::ConnectionDetails.new({ connection_state_ttl: connection_state_ttl_ms }) } 19 | 20 | it 'retrieves attribute :connection_state_ttl and converts it from ms to s' do 21 | expect(subject.connection_state_ttl).to eql(connection_state_ttl_ms / 1000) 22 | end 23 | end 24 | 25 | let(:max_idle_interval) { 6_000 } 26 | 27 | context '#max_idle_interval (#CD2h)' do 28 | subject { Ably::Models::ConnectionDetails.new({ max_idle_interval: max_idle_interval }) } 29 | 30 | it 'retrieves attribute :max_idle_interval and converts it from ms to s' do 31 | expect(subject.max_idle_interval).to eql(max_idle_interval / 1000) 32 | end 33 | end 34 | end 35 | 36 | context '==' do 37 | let(:attributes) { { client_id: 'unique' } } 38 | 39 | it 'is true when attributes are the same' do 40 | connection_details = -> { Ably::Models::ConnectionDetails.new(attributes) } 41 | expect(connection_details.call).to eq(connection_details.call) 42 | end 43 | 44 | it 'is false when attributes are not the same' do 45 | expect(Ably::Models::ConnectionDetails.new(client_id: '1')).to_not eq(Ably::Models::ConnectionDetails.new(client_id: '2')) 46 | end 47 | 48 | it 'is false when class type differs' do 49 | expect(Ably::Models::ConnectionDetails.new(client_id: '1')).to_not eq(nil) 50 | end 51 | end 52 | 53 | context 'ConnectionDetails conversion methods', :api_private do 54 | context 'with a ConnectionDetails object' do 55 | let(:details) { Ably::Models::ConnectionDetails.new(client_id: random_str) } 56 | 57 | it 'returns the ConnectionDetails object' do 58 | expect(Ably::Models::ConnectionDetails(details)).to eql(details) 59 | end 60 | end 61 | 62 | context 'with a JSON object' do 63 | let(:client_id) { random_str } 64 | let(:details_json) { { client_id: client_id } } 65 | 66 | it 'returns a new ConnectionDetails object from the JSON' do 67 | expect(Ably::Models::ConnectionDetails(details_json).client_id).to eql(client_id) 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /ably.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'ably/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'ably' 8 | spec.version = Ably::VERSION 9 | spec.authors = ['Lewis Marshall', "Matthew O'Riordan"] 10 | spec.email = ['lewis@lmars.net', 'matt@ably.io'] 11 | spec.description = %q{A Ruby client library for ably.io realtime messaging} 12 | spec.summary = %q{A Ruby client library for ably.io realtime messaging implemented using EventMachine} 13 | spec.homepage = 'http://github.com/ably/ably-ruby' 14 | spec.license = 'Apache-2.0' 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ['lib'] 20 | 21 | spec.add_runtime_dependency 'eventmachine', '~> 1.2.6' 22 | spec.add_runtime_dependency 'ably-em-http-request', '~> 1.1.8' 23 | spec.add_runtime_dependency 'statesman', '~> 9.0' 24 | spec.add_runtime_dependency 'faraday', '~> 2.2' 25 | spec.add_runtime_dependency 'faraday-typhoeus', '~> 1.1.0' 26 | spec.add_runtime_dependency 'typhoeus', '~> 1.4' 27 | spec.add_runtime_dependency 'json' 28 | # We disallow minor version updates, because this gem has introduced breaking API changes in minor releases before (which it's within its rights to do, given it's pre-v1). If you want to allow a new minor version, bump here and run the tests. 29 | spec.add_runtime_dependency 'websocket-driver', '~> 0.8.0' 30 | spec.add_runtime_dependency 'msgpack', '>= 1.3.0' 31 | spec.add_runtime_dependency 'addressable', '>= 2.0.0' 32 | 33 | spec.add_development_dependency 'rake', '~> 13.0' 34 | spec.add_development_dependency 'redcarpet', '~> 3.3' 35 | spec.add_development_dependency 'rspec', '~> 3.11.0' 36 | spec.add_development_dependency 'rspec_junit_formatter', '~> 0.5.1' 37 | spec.add_development_dependency 'rspec-retry', '~> 0.6' 38 | spec.add_development_dependency 'yard', '~> 0.9' 39 | spec.add_development_dependency 'rspec-instafail', '~> 1.0' 40 | spec.add_development_dependency 'bundler', '>= 1.3.0' 41 | spec.add_development_dependency 'webmock', '~> 3.11' 42 | spec.add_development_dependency 'simplecov', '~> 0.22.0' 43 | spec.add_development_dependency 'simplecov-lcov', '~> 0.8.0' 44 | spec.add_development_dependency 'parallel_tests', '~> 3.8' 45 | spec.add_development_dependency 'pry', '~> 0.14.1' 46 | spec.add_development_dependency 'pry-byebug', '~> 3.8.0' 47 | 48 | if RUBY_VERSION.match(/^3\./) 49 | spec.add_development_dependency 'webrick', '~> 1.7.0' 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 1. Fork it 4 | 2. When pulling to local, make sure to also pull the `ably-common` repo (`git submodule init && git submodule update`) 5 | 3. Create your feature branch (`git checkout -b my-new-feature`) 6 | 4. Commit your changes (`git commit -am 'Add some feature'`) 7 | 5. Ensure you have added suitable tests and the test suite is passing(`bundle exec rspec`) 8 | 6. Push to the branch (`git push origin my-new-feature`) 9 | 7. Create a new Pull Request 10 | 11 | --- 12 | 13 | ## Release process 14 | 15 | This library uses [semantic versioning](http://semver.org/). For each release, the following needs to be done: 16 | 17 | 1. Create a branch for the release, named like `release/1.2.3` (where `1.2.3` is the new version number) 18 | 2. Update the version number in [version.rb](./lib/ably/version.rb) and commit the change. 19 | 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: 20 | - 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). 21 | - Using the command above, `--output delta.md` writes changes made after `--since-tag` to a new file 22 | - The contents of that new file (`delta.md`) then need to be manually inserted at the top of the `CHANGELOG.md`, changing the "Unreleased" heading and linking with the current version numbers 23 | - Also ensure that the "Full Changelog" link points to the new version tag instead of the `HEAD` 24 | 4. Commit this change: `git add CHANGELOG.md && git commit -m "Update change log."` 25 | 5. Ideally, run `rake doc:spec` to generate a new [spec file](./SPEC.md). Then commit these changes. 26 | 6. Make a PR against `main`. Once the PR is approved, merge it into `main`. 27 | 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`. 28 | 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. 29 | 9. Run `rake release` to publish the gem to [Rubygems](https://rubygems.org/gems/ably). 30 | 10. Release the [REST-only library `ably-ruby-rest`](https://github.com/ably/ably-ruby-rest#release-process). 31 | 11. Create the entry on the [Ably Changelog](https://changelog.ably.com/) (via [headwayapp](https://headwayapp.co/)). 32 | -------------------------------------------------------------------------------- /lib/ably/models/device_push_details.rb: -------------------------------------------------------------------------------- 1 | module Ably::Modules 2 | module Conversions 3 | private 4 | # Convert device_push_details argument to a {Ably::Models::DevicePushDetails} object 5 | # 6 | # @param device_push_details [Ably::Models::DevicePushDetails,Hash,nil] A device push notification details object 7 | # 8 | # @return [Ably::Models::DevicePushDetails] 9 | # 10 | def DevicePushDetails(device_push_details) 11 | case device_push_details 12 | when Ably::Models::DevicePushDetails 13 | device_push_details 14 | else 15 | Ably::Models::DevicePushDetails.new(device_push_details) 16 | end 17 | end 18 | end 19 | end 20 | 21 | module Ably::Models 22 | # An object with the push notification details for {DeviceDetails} object 23 | # 24 | class DevicePushDetails < Ably::Exceptions::BaseAblyException 25 | include Ably::Modules::ModelCommon 26 | 27 | # @param hash_object [Hash,nil] Device push detail attributes 28 | # 29 | def initialize(hash_object = {}) 30 | @raw_hash_object = hash_object || {} 31 | @hash_object = IdiomaticRubyWrapper(@raw_hash_object) 32 | end 33 | 34 | # The current state of the push registration. 35 | # 36 | # @spec PCP4 37 | # 38 | # @return [Symbol] 39 | # 40 | def state 41 | attributes[:state] 42 | end 43 | 44 | def state=(val) 45 | unless val.nil? || val.kind_of?(String) 46 | raise ArgumentError, "state must be nil or a string value" 47 | end 48 | attributes[:state] = val 49 | end 50 | 51 | # A JSON object of key-value pairs that contains of the push transport and address. 52 | # 53 | # @spec PCP3 54 | # 55 | # @return [Hash, nil] 56 | # 57 | def recipient 58 | attributes[:recipient] || {} 59 | end 60 | 61 | def recipient=(val) 62 | unless val.nil? || val.kind_of?(Hash) 63 | raise ArgumentError, "recipient must be nil or a Hash value" 64 | end 65 | attributes[:recipient] = val 66 | end 67 | 68 | # An {Ably::Models::ErrorInfo} object describing the most recent error when the state is Failing or Failed. 69 | # 70 | # @spec PCP2 71 | # 72 | # @return [Ably::Models::ErrorInfo] 73 | # 74 | def error_reason 75 | attributes[:error_reason] 76 | end 77 | 78 | def error_reason=(val) 79 | unless val.nil? || val.kind_of?(Hash) || val.kind_of?(Ably::Models::ErrorInfo) 80 | raise ArgumentError, "error_reason must be nil, a Hash value or a ErrorInfo object" 81 | end 82 | 83 | attributes[:error_reason] = if val.nil? 84 | nil 85 | else 86 | ErrorInfo(val) 87 | end 88 | end 89 | 90 | def attributes 91 | @hash_object 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /.ably/capabilities.yaml: -------------------------------------------------------------------------------- 1 | %YAML 1.2 2 | --- 3 | common-version: 1.2.0 4 | compliance: 5 | Agent Identifier: 6 | Agents: 7 | Runtime: 8 | Authentication: 9 | API Key: 10 | Token: 11 | Callback: 12 | Literal: 13 | URL: 14 | Query Time: 15 | Debugging: 16 | Error Information: 17 | Logs: 18 | Protocol: 19 | JSON: 20 | Maximum Message Size: 21 | MessagePack: 22 | Realtime: 23 | Channel: 24 | Attach: 25 | 26 | # setOptions on existing channel instance is missing (i.e. it works but is inelegant, requiring soft-deprecated mechanism) 27 | # see: 28 | # - https://sdk.ably.com/builds/ably/specification/main/features/#RSN3c 29 | # - https://sdk.ably.com/builds/ably/specification/main/features/#RTS3c 30 | Encryption: 31 | 32 | History: 33 | Presence: 34 | Enter: 35 | Client: 36 | Get: 37 | History: 38 | Subscribe: 39 | Update: 40 | Client: 41 | Publish: 42 | Retry Timeout: 43 | State Events: 44 | Subscribe: 45 | Rewind: 46 | Connection: 47 | Disconnected Retry Timeout: 48 | Get Identifier: 49 | Lifecycle Control: 50 | Ping: 51 | Recovery: 52 | State Events: 53 | Suspended Retry Timeout: 54 | Message Echoes: 55 | Message Queuing: 56 | Transport Parameters: 57 | REST: 58 | Authentication: 59 | Authorize: 60 | Create Token Request: 61 | Get Client Identifier: 62 | Request Token: 63 | Channel: 64 | Existence Check: 65 | Get: 66 | History: 67 | Iterate: 68 | Name: 69 | Presence: 70 | History: 71 | Member List: 72 | Publish: 73 | Idempotence: 74 | Release: 75 | Status: 76 | Channel Details: # https://github.com/ably/ably-ruby/pull/365 77 | Opaque Request: 78 | Push Notifications Administration: 79 | Channel Subscription: 80 | List: 81 | List Channels: 82 | Remove: 83 | Save: 84 | Device Registration: 85 | Get: 86 | List: 87 | Remove: 88 | Save: 89 | Publish: 90 | Request Identifiers: 91 | Request Timeout: 92 | Service: 93 | Get Time: 94 | Statistics: 95 | Query: 96 | Service: 97 | Environment: 98 | Fallbacks: 99 | Hosts: 100 | Internet Up Check: 101 | Retry Count: 102 | Retry Duration: 103 | Retry Timeout: 104 | Host: 105 | Testing: 106 | Disable TLS: 107 | TCP Insecure Port: 108 | TCP Secure Port: 109 | Transport: 110 | Connection Open Timeout: 111 | HTTP/2: # https://github.com/ably/ably-ruby/pull/197 112 | Maximum Frame Size: 113 | -------------------------------------------------------------------------------- /lib/ably/realtime/presence/presence_manager.rb: -------------------------------------------------------------------------------- 1 | module Ably::Realtime 2 | class Presence 3 | # PresenceManager is responsible for all actions relating to presence state 4 | # 5 | # This is a private class and should never be used directly by developers as the API is likely to change in future. 6 | # 7 | # @api private 8 | # 9 | class PresenceManager 10 | extend Forwardable 11 | 12 | # {Ably::Realtime::Presence} this Manager is associated with 13 | # @return [Ably::Realtime::Presence] 14 | attr_reader :presence 15 | 16 | def initialize(presence) 17 | @presence = presence 18 | 19 | setup_channel_event_handlers 20 | end 21 | 22 | # @api private 23 | def on_attach(has_presence_flag) 24 | # RTP1 25 | if has_presence_flag 26 | # Expect SYNC ProtocolMessages from the server with a list of current members on this channel 27 | presence.members.change_state :sync_starting 28 | else 29 | # There server has indicated that there are no SYNC ProtocolMessages to come because 30 | # there are no members on this channel 31 | logger.debug { "#{self.class.name}: Emitting leave events for all members as a SYNC is not expected and thus there are no members on the channel" } 32 | presence.members.change_state :sync_none 33 | end 34 | presence.members.enter_local_members # RTP17f 35 | end 36 | 37 | # Process presence messages from SYNC messages. Sync can be server-initiated or triggered following ATTACH 38 | # 39 | # @return [void] 40 | # 41 | # @api private 42 | def sync_process_messages(serial, presence_messages) 43 | unless presence.members.sync_starting? 44 | presence.members.change_state :sync_starting 45 | end 46 | 47 | presence.members.update_sync_serial serial 48 | 49 | presence_messages.each do |presence_message| 50 | presence.__incoming_msgbus__.publish :sync, presence_message 51 | end 52 | 53 | presence.members.change_state :finalizing_sync if presence.members.sync_serial_cursor_at_end? 54 | end 55 | 56 | private 57 | def_delegators :presence, :members, :channel 58 | 59 | def setup_channel_event_handlers 60 | channel.unsafe_on(:detached) do 61 | if !presence.initialized? 62 | presence.transition_state_machine :left if presence.can_transition_to?(:left) 63 | end 64 | end 65 | 66 | channel.unsafe_on(:failed) do |metadata| 67 | if !presence.initialized? 68 | presence.transition_state_machine :left, metadata if presence.can_transition_to?(:left) 69 | end 70 | end 71 | end 72 | 73 | def logger 74 | presence.channel.client.logger 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/ably/models/error_info.rb: -------------------------------------------------------------------------------- 1 | module Ably::Modules 2 | module Conversions 3 | private 4 | # Convert error_details argument to a {ErrorInfo} object 5 | # 6 | # @param error_details [ErrorInfo,Hash] Error info attributes 7 | # 8 | # @return [ErrorInfo] 9 | # 10 | def ErrorInfo(error_details) 11 | case error_details 12 | when Ably::Models::ErrorInfo 13 | error_details 14 | else 15 | Ably::Models::ErrorInfo.new(error_details) 16 | end 17 | end 18 | end 19 | end 20 | 21 | module Ably::Models 22 | # An exception type encapsulating error information containing 23 | # an Ably-specific error code and generic status code. 24 | # 25 | class ErrorInfo < Ably::Exceptions::BaseAblyException 26 | include Ably::Modules::ModelCommon 27 | 28 | def initialize(hash_object) 29 | @raw_hash_object = hash_object 30 | @hash_object = IdiomaticRubyWrapper(hash_object.clone.freeze) 31 | end 32 | 33 | # Ably error code. 34 | # 35 | # @spec TI1 36 | # 37 | # @return [Integer] 38 | # 39 | def code 40 | attributes[:code] 41 | end 42 | 43 | # This is included for REST responses to provide a URL for additional help on the error code. 44 | # 45 | # @spec TI4 46 | # 47 | # @return [String] 48 | # 49 | def href 50 | attributes[:href] 51 | end 52 | 53 | # Additional message information, where available. 54 | # 55 | # @spec TI1 56 | # 57 | # @return [String] 58 | # 59 | def message 60 | attributes[:message] 61 | end 62 | 63 | # Information pertaining to what caused the error where available. 64 | # 65 | # @spec TI1 66 | # 67 | # @return [Ably::Models::ErrorInfo] 68 | # 69 | def cause 70 | attributes[:cause] 71 | end 72 | 73 | # HTTP Status Code corresponding to this error, where applicable. 74 | # 75 | # @spec TI1 76 | # 77 | # @return [Integer] 78 | # 79 | def status_code 80 | attributes[:status_code] 81 | end 82 | 83 | # If a request fails, the request ID must be included in the ErrorInfo returned to the user. 84 | # 85 | # @spec RSC7c 86 | # 87 | # @return [String] 88 | # 89 | def request_id 90 | attributes[:request_id] 91 | end 92 | alias_method :status, :status_code 93 | 94 | def attributes 95 | @hash_object 96 | end 97 | 98 | def to_s 99 | error_href = href || (code ? "https://help.ably.io/error/#{code}" : '') 100 | see_msg = " -> see #{error_href} for help" unless message.to_s.include?(error_href.to_s) 101 | "#{see_msg}" 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/ably/realtime/channels.rb: -------------------------------------------------------------------------------- 1 | module Ably 2 | module Realtime 3 | # Class that maintains a map of Channels ensuring Channels are reused 4 | class Channels 5 | include Ably::Modules::ChannelsCollection 6 | 7 | # @return [Ably::Realtime::Channels] 8 | # 9 | def initialize(client) 10 | super client, Ably::Realtime::Channel 11 | end 12 | 13 | # Return a {Ably::Realtime::Channel} for the given name 14 | # 15 | # @param name [String] The name of the channel 16 | # @param channel_options [Hash, Ably::Models::ChannelOptions] A hash of options or a {Ably::Models::ChannelOptions} 17 | # 18 | # @return [Ably::Realtime::Channel] 19 | # 20 | def get(*args) 21 | super 22 | end 23 | 24 | # Return a {Ably::Realtime::Channel} for the given name if it exists, else the block will be called. 25 | # This method is intentionally similar to {http://ruby-doc.org/core-2.1.3/Hash.html#method-i-fetch Hash#fetch} providing a simple way to check if a channel exists or not without creating one 26 | # 27 | # @param name [String] The name of the channel 28 | # @yield [options] (optional) if a missing_block is passed to this method and no channel exists matching the name, this block is called 29 | # @yieldparam [String] name of the missing channel 30 | # 31 | # @return [Ably::Realtime::Channel] 32 | # 33 | def fetch(*args) 34 | super 35 | end 36 | 37 | # Detaches the {Ably::Realtime::Channel Realtime Channel} and releases all associated resources. 38 | # 39 | # Releasing a Realtime Channel is not typically necessary as a channel, once detached, consumes no resources other than 40 | # the memory footprint of the {Ably::Realtime::Channel Realtime Channel object}. Release channels to free up resources if required 41 | # 42 | # @return [void] 43 | # 44 | def release(channel) 45 | get(channel).detach do 46 | @channels.delete(channel) 47 | end if @channels.has_key?(channel) 48 | end 49 | 50 | # Sets channel serial to each channel from given serials hashmap 51 | # @param [Hash] serials - map of channel name to respective channel serial 52 | # @api private 53 | def set_channel_serials(serials) 54 | serials.each do |channel_name, channel_serial| 55 | get(channel_name).properties.channel_serial = channel_serial 56 | end 57 | end 58 | 59 | # @return [Hash] serials - map of channel name to respective channel serial 60 | # @api private 61 | def get_channel_serials 62 | channel_serials = {} 63 | self.each do |channel| 64 | channel_serials[channel.name] = channel.properties.channel_serial if channel.state == :attached 65 | end 66 | channel_serials 67 | end 68 | 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/ably/modules/message_emitter.rb: -------------------------------------------------------------------------------- 1 | require 'ably/util/pub_sub' 2 | 3 | module Ably::Modules 4 | # Message emitter, subscriber and unsubscriber (Pub/Sub) functionality common to Channels and Presence 5 | # In addition to standard Pub/Sub functionality, it allows subscribers to subscribe to :all. 6 | module MessageEmitter 7 | 8 | include Ably::Modules::SafeYield 9 | 10 | # Subscribe to events on this object 11 | # 12 | # @param names [String,Symbol] Optional, the event name(s) to subscribe to. Defaults to `:all` events 13 | # @yield [Object] For each event, the provided block is called with the event payload object 14 | # 15 | # @return [void] 16 | # 17 | def subscribe(*names, &callback) 18 | raise ArgumentError, 'Block required to subscribe to events' unless block_given? 19 | names = :all unless names && !names.empty? 20 | Array(names).uniq.each do |name| 21 | message_emitter_subscriptions[message_emitter_subscriptions_message_name_key(name)] << callback 22 | end 23 | end 24 | 25 | # Unsubscribe the matching block for events on the this object. 26 | # If a block is not provided, all subscriptions will be unsubscribed 27 | # 28 | # @param names [String,Symbol] Optional, the event name(s) to unsubscribe from. Defaults to `:all` events 29 | # 30 | # @return [void] 31 | # 32 | def unsubscribe(*names, &callback) 33 | names = :all unless names && !names.empty? 34 | Array(names).each do |name| 35 | if name == :all 36 | message_emitter_subscriptions.keys 37 | else 38 | Array(message_emitter_subscriptions_message_name_key(name)) 39 | end.each do |key| 40 | message_emitter_subscriptions[key].delete_if do |block| 41 | !block_given? || callback == block 42 | end 43 | end 44 | end 45 | end 46 | 47 | # Emit a message to message subscribers 48 | # 49 | # param name [String,Symbol] the event name 50 | # param payload [Object] the event object to emit 51 | # 52 | # @return [void] 53 | # 54 | # @api private 55 | def emit_message(name, payload) 56 | message_emitter_subscriptions[:all].each { |cb| safe_yield(cb, payload) } 57 | message_emitter_subscriptions[name].each { |cb| safe_yield(cb, payload) } if name 58 | end 59 | 60 | private 61 | def message_emitter_subscriptions 62 | @message_emitter_subscriptions ||= Hash.new { |hash, key| hash[key] = [] } 63 | end 64 | 65 | def message_emitter_subscriptions_message_name_key(name) 66 | if name == :all 67 | :all 68 | else 69 | message_emitter_subscriptions_coerce_message_key(name) 70 | end 71 | end 72 | 73 | # this method can be overwritten easily to enforce use of set key types§ 74 | def message_emitter_subscriptions_coerce_message_key(name) 75 | name.to_s 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/ably/realtime/client/outgoing_message_dispatcher.rb: -------------------------------------------------------------------------------- 1 | module Ably::Realtime 2 | class Client 3 | # OutgoingMessageDispatcher is a (private) class that is used to deliver 4 | # outgoing {Ably::Models::ProtocolMessage}s using the {Ably::Realtime::Connection} 5 | # when the connection state is capable of delivering messages 6 | class OutgoingMessageDispatcher 7 | include Ably::Modules::EventMachineHelpers 8 | 9 | ACTION = Ably::Models::ProtocolMessage::ACTION 10 | 11 | def initialize(client, connection) 12 | @client = client 13 | @connection = connection 14 | 15 | subscribe_to_outgoing_protocol_message_queue 16 | setup_event_handlers 17 | end 18 | 19 | private 20 | def client 21 | @client 22 | end 23 | 24 | def connection 25 | @connection 26 | end 27 | 28 | def can_send_messages? 29 | connection.connected? || connection.closing? 30 | end 31 | 32 | def messages_in_outgoing_queue? 33 | !outgoing_queue.empty? 34 | end 35 | 36 | def outgoing_queue 37 | connection.__outgoing_message_queue__ 38 | end 39 | 40 | def pending_ack_queue 41 | connection.__pending_message_ack_queue__ 42 | end 43 | 44 | def current_transport_outgoing_message_bus 45 | connection.transport.__outgoing_protocol_msgbus__ 46 | end 47 | 48 | def deliver_queued_protocol_messages 49 | condition = -> { can_send_messages? && messages_in_outgoing_queue? } 50 | 51 | non_blocking_loop_while(condition) do 52 | protocol_message = outgoing_queue.shift 53 | 54 | if (!connection.transport) 55 | protocol_message.fail Ably::Exceptions::TransportClosed.new('Transport disconnected unexpectedly', nil, Ably::Exceptions::Codes::DISCONNECTED) 56 | next 57 | end 58 | 59 | current_transport_outgoing_message_bus.publish :protocol_message, protocol_message 60 | 61 | if protocol_message.ack_required? 62 | pending_ack_queue << protocol_message 63 | else 64 | protocol_message.succeed protocol_message 65 | end 66 | end 67 | end 68 | 69 | def subscribe_to_outgoing_protocol_message_queue 70 | connection.__outgoing_protocol_msgbus__.subscribe(:protocol_message) do |*args| 71 | deliver_queued_protocol_messages 72 | end 73 | end 74 | 75 | def setup_event_handlers 76 | connection.unsafe_on(:connected) do 77 | # Give connection manager enough time to prevent message delivery if necessary 78 | # For example, if reconnecting and connection and channel state is lost, 79 | # then the queued messages must be NACK'd 80 | EventMachine.next_tick do 81 | deliver_queued_protocol_messages 82 | end 83 | end 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/shared/model_behaviour.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | shared_examples 'a model' do |shared_options = {}| 4 | let(:base_model_options) { shared_options.fetch(:base_model_options, {}) } 5 | let(:args) { ([base_model_options.merge(model_options)] + model_args) } 6 | let(:model) { subject.new(*args) } 7 | 8 | context 'attributes' do 9 | let(:unique_value) { random_str } 10 | 11 | Array(shared_options[:with_simple_attributes]).each do |attribute| 12 | context "##{attribute}" do 13 | let(:model_options) { { attribute.to_sym => unique_value } } 14 | 15 | it "retrieves attribute :#{attribute}" do 16 | expect(model.public_send(attribute)).to eql(unique_value) 17 | end 18 | end 19 | end 20 | 21 | context '#attributes', :api_private do 22 | let(:model_options) { { action: 5, max_message_size: 65536, max_frame_size: 524288 } } 23 | 24 | it 'provides access to #attributes' do 25 | expect(model.attributes).to eq(model_options) 26 | end 27 | end 28 | 29 | context '#[]', :api_private do 30 | let(:model_options) { { unusual: 'attribute' } } 31 | 32 | it 'provides accessor method to #attributes' do 33 | expect(model[:unusual]).to eql('attribute') 34 | end 35 | end 36 | end 37 | 38 | context '#==' do 39 | let(:model_options) { { channel: 'unique' } } 40 | 41 | it 'is true when attributes are the same' do 42 | new_message = -> { subject.new(*args) } 43 | expect(new_message[]).to eq(new_message[]) 44 | end 45 | 46 | it 'is false when attributes are not the same' do 47 | expect(subject.new(*[action: 1] + model_args)).to_not eq(subject.new(*[action: 2] + model_args)) 48 | end 49 | 50 | it 'is false when class type differs' do 51 | expect(subject.new(*[action: 1] + model_args)).to_not eq(nil) 52 | end 53 | end 54 | 55 | context '#to_msgpack', :api_private do 56 | let(:model_options) { { name: 'test', action: 0, channel_snake_case: 'unique' } } 57 | let(:serialized) { model.to_msgpack } 58 | 59 | it 'returns a msgpack object with Ably payload naming' do 60 | expect(MessagePack.unpack(serialized)).to include('channelSnakeCase' => 'unique') 61 | end 62 | end 63 | 64 | context '#to_json', :api_private do 65 | let(:model_options) { { name: 'test', action: 0, channel_snake_case: 'unique' } } 66 | let(:serialized) { model.to_json } 67 | 68 | it 'returns a JSON string with Ably payload naming' do 69 | expect(JSON.parse(serialized)).to include('channelSnakeCase' => 'unique') 70 | end 71 | end 72 | 73 | context 'is immutable' do 74 | let(:model_options) { { channel: 'name' } } 75 | 76 | it 'prevents changes' do 77 | expect { model.attributes[:channel] = 'new' }.to raise_error RuntimeError, /can't modify frozen.*Hash/ 78 | end 79 | 80 | it 'dups options' do 81 | expect(model.attributes[:channel]).to eql('name') 82 | model_options[:channel] = 'new' 83 | expect(model.attributes[:channel]).to eql('name') 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/unit/util/pub_sub_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ably::Util::PubSub do 4 | let(:options) { {} } 5 | let(:obj) { double('example') } 6 | let(:msg) { double('message') } 7 | 8 | subject { Ably::Util::PubSub.new(options) } 9 | 10 | context 'event fan out' do 11 | specify '#publish allows publishing to more than on subscriber' do 12 | expect(obj).to receive(:received_message).with(msg).twice 13 | 2.times do 14 | subject.subscribe(:message) { |msg| obj.received_message msg } 15 | end 16 | subject.publish :message, msg 17 | end 18 | 19 | it '#publish sends only messages to #subscribe callbacks matching event names' do 20 | expect(obj).to receive(:received_message).with(msg).once 21 | subject.subscribe(:valid) { |msg| obj.received_message msg } 22 | subject.publish :valid, msg 23 | subject.publish :ignored, msg 24 | subject.publish 'valid', msg 25 | end 26 | 27 | context 'with coercion', api_private: true do 28 | let(:options) do 29 | { coerce_into: lambda { |event| String(event) } } 30 | end 31 | 32 | it 'calls the provided proc to coerce the event name' do 33 | expect(obj).to receive(:received_message).with(msg).once 34 | subject.subscribe('valid') { |msg| obj.received_message msg } 35 | subject.publish :valid, msg 36 | end 37 | 38 | context 'and two different configurations but sharing the same class' do 39 | let!(:exception_pubsub) { Ably::Util::PubSub.new(coerce_into: lambda { |event| raise KeyError }) } 40 | 41 | it 'does not share state' do 42 | expect(obj).to receive(:received_message).with(msg).once 43 | subject.subscribe('valid') { |msg| obj.received_message msg } 44 | subject.publish :valid, msg 45 | 46 | expect { exception_pubsub.publish :fail }.to raise_error KeyError 47 | end 48 | end 49 | end 50 | 51 | context 'without coercion', api_private: true do 52 | it 'only matches event names on type matches' do 53 | expect(obj).to_not receive(:received_message).with(msg) 54 | subject.subscribe('valid') { |msg| obj.received_message msg } 55 | subject.publish :valid, msg 56 | end 57 | end 58 | end 59 | 60 | context '#unsubscribe' do 61 | let(:callback) { lambda { |msg| obj.received_message msg } } 62 | 63 | before do 64 | subject.subscribe(:message, &callback) 65 | end 66 | 67 | after do 68 | subject.publish :message, msg 69 | end 70 | 71 | it 'deletes matching callbacks' do 72 | expect(obj).to_not receive(:received_message).with(msg) 73 | subject.unsubscribe(:message, &callback) 74 | end 75 | 76 | it 'deletes all callbacks if not block given' do 77 | expect(obj).to_not receive(:received_message).with(msg) 78 | subject.unsubscribe(:message) 79 | end 80 | 81 | it 'continues if the block does not exist' do 82 | expect(obj).to receive(:received_message).with(msg) 83 | subject.unsubscribe(:message) { true } 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/ably/modules/safe_deferrable.rb: -------------------------------------------------------------------------------- 1 | require 'eventmachine' 2 | 3 | module Ably::Modules 4 | # SafeDeferrable module provides an EventMachine::Deferrable interface to the object it is included in 5 | # and is safe to use for for public interfaces of this client library. 6 | # Any exceptions raised in the success or failure callbacks are caught and logged to #logger 7 | # 8 | # An exception in a callback provided by a developer should not break this client library 9 | # and stop further execution of code. 10 | # 11 | # @note this Module requires that the method #logger is available 12 | # 13 | # See http://www.rubydoc.info/gems/eventmachine/1.0.7/EventMachine/Deferrable 14 | # 15 | module SafeDeferrable 16 | include EventMachine::Deferrable 17 | 18 | # Specify a block to be executed if and when the Deferrable object receives 19 | # a status of :succeeded. 20 | # See http://www.rubydoc.info/gems/eventmachine/1.0.7/EventMachine/Deferrable#callback-instance_method 21 | # 22 | # @return [void] 23 | # 24 | def callback(&block) 25 | super do |*args| 26 | safe_deferrable_block(*args, &block) 27 | end 28 | end 29 | 30 | # Specify a block to be executed if and when the Deferrable object receives 31 | # a status of :failed. 32 | # See http://www.rubydoc.info/gems/eventmachine/1.0.7/EventMachine/Deferrable#errback-instance_method 33 | # 34 | # @return [void] 35 | # 36 | def errback(&block) 37 | super do |*args| 38 | safe_deferrable_block(*args, &block) 39 | end 40 | end 41 | 42 | # Mark the Deferrable as succeeded and trigger all success callbacks. 43 | # See http://www.rubydoc.info/gems/eventmachine/1.0.7/EventMachine/Deferrable#succeed-instance_method 44 | # 45 | # @return [void] 46 | # 47 | def succeed(*args) 48 | super(*args) 49 | end 50 | 51 | # Mark the Deferrable as failed and trigger all error callbacks. 52 | # See http://www.rubydoc.info/gems/eventmachine/1.0.7/EventMachine/Deferrable#fail-instance_method 53 | # 54 | # @return [void] 55 | # 56 | def fail(*args) 57 | super(*args) 58 | end 59 | 60 | private 61 | def safe_deferrable_block(*args) 62 | yield(*args) 63 | rescue StandardError => e 64 | message = "An exception in a Deferrable callback was caught. #{e.class}: #{e.message}\n#{e.backtrace.join("\n")}" 65 | if defined?(:logger) && logger.respond_to?(:error) 66 | logger.error message 67 | else 68 | fallback_logger.error message 69 | end 70 | end 71 | 72 | def fallback_logger 73 | @fallback_logger ||= ::Logger.new(STDOUT).tap do |logger| 74 | logger.formatter = lambda do |severity, datetime, progname, msg| 75 | [ 76 | "#{datetime.strftime("%Y-%m-%d %H:%M:%S.%L")} #{::Logger::SEV_LABEL[severity]} #{msg}", 77 | "Warning: SafeDeferrable expects the method #logger to be defined in the class it is included in, the method was not found in #{self.class}" 78 | ].join("\n") 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/unit/models/error_info_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'shared/model_behaviour' 3 | 4 | describe Ably::Models::ErrorInfo do 5 | subject { Ably::Models::ErrorInfo } 6 | 7 | context '#TI1, #TI4' do 8 | it_behaves_like 'a model', with_simple_attributes: %w(code status_code href message request_id cause) do 9 | let(:model_args) { [] } 10 | end 11 | end 12 | 13 | context '#status #TI1, #TI2' do 14 | subject { Ably::Models::ErrorInfo.new('statusCode' => 401) } 15 | it 'is an alias for #status_code' do 16 | expect(subject.status).to eql(subject.status_code) 17 | expect(subject.status).to eql(401) 18 | end 19 | end 20 | 21 | context '#request_id #RSC7c' do 22 | subject { Ably::Models::ErrorInfo.new('request_id' => '123-456-789-001') } 23 | 24 | it 'should return request ID' do 25 | expect(subject.request_id).to eql('123-456-789-001') 26 | end 27 | end 28 | 29 | context '#cause #TI1' do 30 | subject { Ably::Models::ErrorInfo.new('cause' => Ably::Models::ErrorInfo.new({})) } 31 | 32 | it 'should return cause attribute' do 33 | expect(subject.cause).to be_kind_of(Ably::Models::ErrorInfo) 34 | end 35 | end 36 | 37 | context 'log entries container help link #TI5' do 38 | context 'without an error code' do 39 | subject { Ably::Models::ErrorInfo.new('statusCode' => 401) } 40 | 41 | it 'does not include the help URL' do 42 | expect(subject.to_s.scan(/help\.ably\.io/)).to be_empty 43 | end 44 | end 45 | 46 | context 'with a specified error code' do 47 | subject { Ably::Models::ErrorInfo.new('code' => 44444) } 48 | 49 | it 'includes https://help.ably.io/error/[CODE] in the stringified object' do 50 | expect(subject.to_s).to include('https://help.ably.io/error/44444') 51 | end 52 | end 53 | 54 | context 'with an error code and an href attribute' do 55 | subject { Ably::Models::ErrorInfo.new('code' => 44444, 'href' => 'http://foo.bar.com/') } 56 | 57 | it 'includes the specified href in the stringified object' do 58 | expect(subject.to_s).to include('http://foo.bar.com/') 59 | expect(subject.to_s).to_not include('https://help.ably.io/error/44444') 60 | end 61 | end 62 | 63 | context 'with an error code and a message with the same error URL' do 64 | subject { Ably::Models::ErrorInfo.new('message' => 'error https://help.ably.io/error/44444', 'code' => 44444) } 65 | 66 | it 'includes the specified error URL only once in the stringified object' do 67 | expect(subject.to_s.scan(/help.ably.io/).length).to eql(1) 68 | end 69 | end 70 | 71 | context 'with an error code and a message with a different error URL' do 72 | subject { Ably::Models::ErrorInfo.new('message' => 'error https://help.ably.io/error/123123', 'code' => 44444) } 73 | 74 | it 'includes the specified error URL from the message and the error code URL in the stringified object' do 75 | puts subject.to_s 76 | expect(subject.to_s.scan(/help.ably.io/).length).to eql(2) 77 | expect(subject.to_s.scan(%r{error/123123}).length).to eql(1) 78 | expect(subject.to_s.scan(%r{error/44444}).length).to eql(1) 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/ably/models/message_encoders/cipher.rb: -------------------------------------------------------------------------------- 1 | require 'ably/exceptions' 2 | require 'ably/models/message_encoders/base' 3 | require 'ably/util/crypto' 4 | 5 | module Ably::Models::MessageEncoders 6 | # Cipher Encoder & Decoder that automatically encrypts & decrypts messages using Ably::Util::Crypto 7 | # when a channel has the +:cipher+ channel option configured 8 | # 9 | class Cipher < Base 10 | ENCODING_ID = 'cipher' 11 | 12 | def initialize(*args) 13 | super 14 | @cryptos = Hash.new 15 | end 16 | 17 | def encode(message, channel_options) 18 | return if is_empty?(message) 19 | return if already_encrypted?(message) 20 | 21 | if channel_configured_for_encryption?(channel_options) 22 | add_encoding_to_message 'utf-8', message unless is_binary?(message) || is_utf8_encoded?(message) 23 | crypto = crypto_for(channel_options) 24 | message[:data] = crypto.encrypt(message[:data]) 25 | add_encoding_to_message "#{ENCODING_ID}+#{crypto.cipher_params.cipher_type.downcase}", message 26 | end 27 | rescue ArgumentError => e 28 | raise Ably::Exceptions::CipherError.new(e.message, nil, 92005) 29 | rescue RuntimeError => e 30 | if e.message.match(/unsupported cipher algorithm/i) 31 | raise Ably::Exceptions::CipherError.new(e.message, nil, 92004) 32 | else 33 | raise e 34 | end 35 | end 36 | 37 | def decode(message, channel_options) 38 | if is_cipher_encoded?(message) 39 | unless channel_configured_for_encryption?(channel_options) 40 | raise Ably::Exceptions::CipherError.new('Message cannot be decrypted as the channel is not set up for encryption & decryption', nil, 92001) 41 | end 42 | 43 | crypto = crypto_for(channel_options) 44 | unless crypto.cipher_params.cipher_type == cipher_algorithm(message).upcase 45 | raise Ably::Exceptions::CipherError.new("Cipher algorithm #{crypto.cipher_params.cipher_type} does not match message cipher algorithm of #{cipher_algorithm(message).upcase}", nil, 92002) 46 | end 47 | 48 | message[:data] = crypto.decrypt(message[:data]) 49 | strip_current_encoding_part message 50 | end 51 | rescue OpenSSL::Cipher::CipherError => e 52 | raise Ably::Exceptions::CipherError.new("CipherError decrypting data, the private key may not be correct", nil, 92003) 53 | end 54 | 55 | private 56 | def is_binary?(message) 57 | message.fetch(:data, '').encoding == Encoding::ASCII_8BIT 58 | end 59 | 60 | def is_utf8_encoded?(message) 61 | current_encoding_part(message).to_s.match(/^utf-8$/i) 62 | end 63 | 64 | def crypto_for(channel_options) 65 | @cryptos[channel_options.to_s] ||= Ably::Util::Crypto.new(channel_options.fetch(:cipher, {})) 66 | end 67 | 68 | def channel_configured_for_encryption?(channel_options) 69 | channel_options[:cipher] 70 | end 71 | 72 | def is_cipher_encoded?(message) 73 | !cipher_algorithm(message).nil? 74 | end 75 | 76 | def cipher_algorithm(message) 77 | current_encoding_part(message).to_s[/^#{ENCODING_ID}\+([\w-]+)$/, 1] 78 | end 79 | 80 | def already_encrypted?(message) 81 | message.fetch(:encoding, '').to_s.match(%r{(^|/)#{ENCODING_ID}\+([\w-]+)($|/)}) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/support/test_app.rb: -------------------------------------------------------------------------------- 1 | require 'singleton' 2 | 3 | class TestApp 4 | TEST_RESOURCES_PATH = File.expand_path('../../../lib/submodules/ably-common/test-resources', __FILE__) 5 | 6 | # App configuration for test app 7 | # See https://github.com/ably/ably-common/blob/main/test-resources/test-app-setup.json 8 | APP_SPEC = JSON.parse(File.read(File.join(TEST_RESOURCES_PATH, 'test-app-setup.json')))['post_apps'] 9 | 10 | # Cipher details used for client_encoded presence data in test app 11 | # See https://github.com/ably/ably-common/blob/main/test-resources/test-app-setup.json 12 | APP_SPEC_CIPHER = JSON.parse(File.read(File.join(TEST_RESOURCES_PATH, 'test-app-setup.json')))['cipher'] 13 | 14 | # If an app has already been created and we need a new app, create a new test app 15 | # This is sometimes needed when a test needs to be isolated from any other tests 16 | def self.reload 17 | if instance_variable_get('@singleton__instance__') 18 | instance.delete 19 | instance.create_test_app 20 | end 21 | end 22 | 23 | include Singleton 24 | 25 | def initialize 26 | create_test_app 27 | end 28 | 29 | def app_id 30 | @attributes.fetch('appId') 31 | end 32 | 33 | def key 34 | @attributes.fetch('keys').first 35 | end 36 | 37 | def restricted_key 38 | @attributes.fetch('keys')[1] 39 | end 40 | 41 | def key_name 42 | key.fetch('keyName') 43 | end 44 | 45 | def key_secret 46 | key.fetch('keySecret') 47 | end 48 | 49 | def api_key 50 | key.fetch('keyStr') 51 | end 52 | 53 | def restricted_api_key 54 | restricted_key.fetch('keyStr') 55 | end 56 | 57 | def delete 58 | return unless TestApp.instance_variable_get('@singleton__instance__') 59 | 60 | url = "#{sandbox_client.endpoint}/apps/#{app_id}" 61 | 62 | basic_auth = Base64.urlsafe_encode64(api_key).chomp 63 | headers = { "Authorization" => "Basic #{basic_auth}" } 64 | 65 | Faraday.delete(url, nil, headers) 66 | end 67 | 68 | def environment 69 | ENV['ABLY_ENV'] || 'sandbox' 70 | end 71 | 72 | def create_test_app 73 | url = "#{sandbox_client.endpoint}/apps" 74 | 75 | headers = { 76 | 'Accept' => 'application/json', 77 | 'Content-Type' => 'application/json' 78 | } 79 | 80 | response = Faraday.post(url, APP_SPEC.to_json, headers) 81 | raise "Could not create test app. Ably responded with status #{response.status}\n#{response.body}" unless (200..299).include?(response.status) 82 | 83 | @attributes = JSON.parse(response.body) 84 | 85 | puts "Test app '#{app_id}' created in #{environment} environment" 86 | end 87 | 88 | def host 89 | sandbox_client.endpoint.host 90 | end 91 | 92 | def realtime_host 93 | host.gsub(/rest/, 'realtime') 94 | end 95 | 96 | def create_test_stats(stats) 97 | client = Ably::Rest::Client.new(key: api_key, environment: environment) 98 | response = client.post('/stats', stats) 99 | raise "Could not create stats fixtures. Ably responded with status #{response.status}\n#{response.body}" unless (200..299).include?(response.status) 100 | end 101 | 102 | private 103 | def sandbox_client 104 | @sandbox_client ||= Ably::Rest::Client.new(key: 'app.key:secret', tls: true, environment: environment) 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/unit/models/push_channel_subscription_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | require 'shared/model_behaviour' 4 | 5 | describe Ably::Models::PushChannelSubscription do 6 | include Ably::Modules::Conversions 7 | 8 | subject { Ably::Models::PushChannelSubscription } 9 | 10 | %w(channel client_id device_id).each do |string_attribute| 11 | describe "##{string_attribute} and ##{string_attribute}=" do 12 | let(:empty_device_details) do 13 | if string_attribute == 'device_id' 14 | subject.new(channel: 'default', device_id: 'default') 15 | else 16 | subject.new(channel: 'default', client_id: 'default') 17 | end 18 | end 19 | let(:new_val) { random_str } 20 | 21 | specify 'setter accepts a string value and getter returns the new value' do 22 | expect(empty_device_details.public_send(string_attribute)).to eql('default') 23 | empty_device_details.public_send("#{string_attribute}=", new_val) 24 | expect(empty_device_details.public_send(string_attribute)).to eql(new_val) 25 | end 26 | 27 | specify 'setter accepts nil' do 28 | empty_device_details.public_send("#{string_attribute}=", new_val) 29 | expect(empty_device_details.public_send(string_attribute)).to eql(new_val) 30 | empty_device_details.public_send("#{string_attribute}=", nil) 31 | expect(empty_device_details.public_send(string_attribute)).to be_nil 32 | end 33 | 34 | specify 'rejects non string or nil values' do 35 | expect { empty_device_details.public_send("#{string_attribute}=", {}) }.to raise_error(ArgumentError) 36 | end 37 | end 38 | end 39 | 40 | context 'camelCase constructor attributes' do 41 | let(:client_id) { random_str } 42 | let(:device_details) { subject.new(channel: 'foo', 'clientId' => client_id ) } 43 | 44 | specify 'are rubyfied and exposed as underscore case' do 45 | expect(device_details.client_id).to eql(client_id) 46 | end 47 | 48 | specify 'are generated when the object is serialised to JSON' do 49 | expect(JSON.parse(device_details.to_json)["clientId"]).to eql(client_id) 50 | end 51 | end 52 | 53 | describe 'conversion method PushChannelSubscription' do 54 | let(:channel) { 'foo' } 55 | let(:device_id) { 'bar' } 56 | 57 | it 'accepts a PushChannelSubscription object' do 58 | push_channel_sub = PushChannelSubscription(channel: channel, device_id: device_id) 59 | expect(push_channel_sub.channel).to eql('foo') 60 | expect(push_channel_sub.client_id).to be_nil 61 | expect(push_channel_sub.device_id).to eql('bar') 62 | end 63 | end 64 | 65 | describe '#for_client_id constructor' do 66 | context 'with a valid object' do 67 | let(:channel) { 'foo' } 68 | let(:client_id) { 'bob' } 69 | 70 | it 'accepts a Hash object' do 71 | push_channel_sub = Ably::Models::PushChannelSubscription.for_client_id(channel, client_id) 72 | expect(push_channel_sub.channel).to eql('foo') 73 | expect(push_channel_sub.client_id).to eql('bob') 74 | expect(push_channel_sub.device_id).to be_nil 75 | end 76 | end 77 | 78 | context 'with an invalid valid object' do 79 | let(:subscription) { { channel: 'foo' } } 80 | 81 | it 'accepts a Hash object' do 82 | expect { Ably::Models::PushChannelSubscription.for_device(subscription) }.to raise_error(ArgumentError) 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/ably/models/channel_metrics.rb: -------------------------------------------------------------------------------- 1 | module Ably::Models 2 | # Convert token details argument to a {ChannelMetrics} object 3 | # 4 | # @param attributes (see #initialize) 5 | # 6 | # @return [ChannelMetrics] 7 | # 8 | def self.ChannelMetrics(attributes) 9 | case attributes 10 | when ChannelMetrics 11 | return attributes 12 | else 13 | ChannelMetrics.new(attributes) 14 | end 15 | end 16 | 17 | # Contains the metrics associated with a {Ably::Models::Rest::Channel} or {Ably::Models::Realtime::Channel}, 18 | # such as the number of publishers, subscribers and connections it has. 19 | # 20 | # @spec CHM1 21 | # 22 | class ChannelMetrics 23 | extend Ably::Modules::Enum 24 | extend Forwardable 25 | include Ably::Modules::ModelCommon 26 | 27 | # The attributes of ChannelMetrics (CHM2) 28 | # 29 | attr_reader :attributes 30 | 31 | alias_method :to_h, :attributes 32 | 33 | # Initialize a new ChannelMetrics 34 | # 35 | def initialize(attrs) 36 | @attributes = IdiomaticRubyWrapper(attrs.clone) 37 | end 38 | 39 | # The number of realtime connections attached to the channel. 40 | # 41 | # @spec CHM2a 42 | # 43 | # @return [Integer] 44 | # 45 | def connections 46 | attributes[:connections] 47 | end 48 | 49 | # The number of realtime connections attached to the channel with permission to enter the presence set, regardless 50 | # of whether or not they have entered it. This requires the presence capability and for a client to not have specified 51 | # a {Ably::Models::ChannelOptions::MODES} flag that excludes {Ably::Models::ChannelOptions::MODES}#PRESENCE. 52 | # 53 | # @spec CHM2b 54 | # 55 | # @return [Integer] 56 | # 57 | def presence_connections 58 | attributes[:presence_connections] 59 | end 60 | 61 | # The number of members in the presence set of the channel. 62 | # 63 | # @spec CHM2c 64 | # 65 | # @return [Integer] 66 | # 67 | def presence_members 68 | attributes[:presence_members] 69 | end 70 | 71 | # The number of realtime attachments receiving presence messages on the channel. This requires the subscribe capability 72 | # and for a client to not have specified a {Ably::Models::ChannelOptions::MODES} flag that excludes 73 | # {Ably::Models::ChannelOptions::MODES}#PRESENCE_SUBSCRIBE. 74 | # 75 | # @spec CHM2d 76 | # 77 | # @return [Integer] 78 | # 79 | def presence_subscribers 80 | attributes[:presence_subscribers] 81 | end 82 | 83 | # The number of realtime attachments permitted to publish messages to the channel. This requires the publish 84 | # capability and for a client to not have specified a {Ably::Models::ChannelOptions::MODES} flag that excludes 85 | # {Ably::Models::ChannelOptions::MODES}#PUBLISH. 86 | # 87 | # @spec CHM2e 88 | # 89 | # @return [Integer] 90 | # 91 | def publishers 92 | attributes[:publishers] 93 | end 94 | 95 | # The number of realtime attachments receiving messages on the channel. This requires the subscribe capability and 96 | # for a client to not have specified a {Ably::Models::ChannelOptions::MODES} flag that excludes 97 | # {Ably::Models::ChannelOptions::MODES}#SUBSCRIBE. 98 | # 99 | # @spec CHM2f 100 | # 101 | # @return [Integer] 102 | # 103 | def subscribers 104 | attributes[:subscribers] 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/acceptance/rest/channels_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe Ably::Rest::Channels do 5 | shared_examples 'a channel' do 6 | it 'returns a channel object' do 7 | expect(channel).to be_a Ably::Rest::Channel 8 | expect(channel.name).to eq(channel_name) 9 | end 10 | 11 | it 'returns channel object and passes the provided options' do 12 | expect(channel_with_options.options.to_h).to eq(options) 13 | end 14 | end 15 | 16 | vary_by_protocol do 17 | let(:client) do 18 | Ably::Rest::Client.new(key: api_key, environment: environment, protocol: protocol) 19 | end 20 | let(:channel_name) { random_str } 21 | let(:options) { { key: 'value' } } 22 | 23 | describe 'using shortcut method #channel on the client object' do 24 | let(:channel) { client.channel(channel_name) } 25 | let(:channel_with_options) { client.channel(channel_name, options) } 26 | it_behaves_like 'a channel' 27 | end 28 | 29 | describe 'using #get method on client#channels' do 30 | let(:channel) { client.channels.get(channel_name) } 31 | let(:channel_with_options) { client.channels.get(channel_name, options) } 32 | it_behaves_like 'a channel' 33 | end 34 | 35 | describe '#set_options (#RTL16)' do 36 | let(:channel) { client.channel(channel_name) } 37 | 38 | it "updates channel's options" do 39 | expect { channel.options = options }.to change { channel.options.to_h }.from({}).to(options) 40 | end 41 | 42 | context 'when providing Ably::Models::ChannelOptions object' do 43 | let(:options_object) { Ably::Models::ChannelOptions.new(options) } 44 | 45 | it "updates channel's options" do 46 | expect { channel.options = options_object}.to change { channel.options.to_h }.from({}).to(options) 47 | end 48 | end 49 | end 50 | 51 | describe 'accessing an existing channel object with different options' do 52 | let(:new_channel_options) { { encrypted: true } } 53 | let(:original_channel) { client.channels.get(channel_name, options) } 54 | 55 | it 'overrides the existing channel options and returns the channel object (RSN3c)' do 56 | expect(original_channel.options.to_h).to_not include(:encrypted) 57 | 58 | new_channel = client.channels.get(channel_name, new_channel_options) 59 | expect(new_channel).to be_a(Ably::Rest::Channel) 60 | expect(new_channel.options[:encrypted]).to eql(true) 61 | end 62 | end 63 | 64 | describe 'accessing an existing channel object without specifying any channel options' do 65 | let(:original_channel) { client.channels.get(channel_name, options) } 66 | 67 | it 'returns the existing channel without modifying the channel options' do 68 | expect(original_channel.options.to_h).to eq(options) 69 | new_channel = client.channels.get(channel_name) 70 | expect(new_channel).to be_a(Ably::Rest::Channel) 71 | expect(original_channel.options.to_h).to eq(options) 72 | end 73 | end 74 | 75 | describe 'using undocumented array accessor [] method on client#channels' do 76 | let(:channel) { client.channels[channel_name] } 77 | let(:channel_with_options) { client.channels[channel_name, options] } 78 | it_behaves_like 'a channel' 79 | end 80 | 81 | describe 'using a frozen channel name' do 82 | let(:channel) { client.channels[channel_name.freeze] } 83 | let(:channel_with_options) { client.channels[channel_name.freeze, options] } 84 | it_behaves_like 'a channel' 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/ably/models/http_paginated_response.rb: -------------------------------------------------------------------------------- 1 | require 'ably/models/paginated_result' 2 | 3 | module Ably::Models 4 | # HTTP respones object from Rest#request object 5 | # Wraps any Ably HTTP response that supports paging and provides methods to iterate through 6 | # the pages using {#first}, {#next}, {#has_next?} and {#last?} 7 | 8 | class HttpPaginatedResponse < PaginatedResult 9 | # Retrieve the first page of results. 10 | # When used as part of the {Ably::Realtime} library, it will return a {Ably::Util::SafeDeferrable} object, 11 | # and allows an optional success callback block to be provided. 12 | # 13 | # @return [HttpPaginatedResponse,Ably::Util::SafeDeferrable] 14 | # 15 | def first(&success_callback) 16 | async_wrap_if_realtime(success_callback) do 17 | return nil unless supports_pagination? 18 | HttpPaginatedResponse.new(client.get(pagination_url('first')), base_url, client, pagination_options, &each_block) 19 | end 20 | end 21 | 22 | # Retrieve the next page of results. 23 | # When used as part of the {Ably::Realtime} library, it will return a {Ably::Util::SafeDeferrable} object, 24 | # and allows an optional success callback block to be provided. 25 | # 26 | # @return [HttpPaginatedResponse,Ably::Util::SafeDeferrable] 27 | # 28 | def next(&success_callback) 29 | async_wrap_if_realtime(success_callback) do 30 | return nil unless has_next? 31 | HttpPaginatedResponse.new(client.get(pagination_url('next')), base_url, client, pagination_options, &each_block) 32 | end 33 | end 34 | 35 | # The HTTP status code of the response. 36 | # 37 | # @spec HP4 38 | # 39 | # @return [Integer] 40 | # 41 | def status_code 42 | http_response.status.to_i 43 | end 44 | 45 | # Whether statusCode indicates success. This is equivalent to 200 <= statusCode < 300. 46 | # 47 | # @spec HP5 48 | # 49 | # @return [Boolean] 50 | # 51 | def success? 52 | (200..299).include?(http_response.status.to_i) 53 | end 54 | 55 | # The error code if the X-Ably-Errorcode HTTP header is sent in the response. 56 | # 57 | # @spec HP6 58 | # 59 | # @return [Integer] 60 | # 61 | def error_code 62 | if http_response.headers['X-Ably-Errorcode'] 63 | http_response.headers['X-Ably-Errorcode'].to_i 64 | end 65 | end 66 | 67 | # The error message if the X-Ably-Errormessage HTTP header is sent in the response. 68 | # 69 | # @spec HP7 70 | # 71 | # @return [String] 72 | # 73 | def error_message 74 | http_response.headers['X-Ably-Errormessage'] 75 | end 76 | 77 | # The headers of the response. 78 | # 79 | # @spec HP8 80 | # 81 | # @return [Hash] 82 | # 83 | def headers 84 | http_response.headers || {} 85 | end 86 | 87 | # Farady compatible response object used when an exception is raised 88 | # @api private 89 | class ErrorResponse 90 | def initialize(status, error_code, error_message) 91 | @status = status 92 | @error_code = error_code 93 | @error_message = error_message 94 | end 95 | 96 | def status 97 | @status 98 | end 99 | 100 | def headers 101 | { 102 | 'X-Ably-Errorcode' => @error_code, 103 | 'X-Ably-Errormessage' => @error_message 104 | } 105 | end 106 | 107 | def body 108 | nil 109 | end 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/ably/realtime/channel/publisher.rb: -------------------------------------------------------------------------------- 1 | module Ably::Realtime 2 | class Channel 3 | # Publisher module adds publishing capabilities to the current object 4 | module Publisher 5 | private 6 | 7 | # Prepare and queue messages on the connection queue immediately 8 | # 9 | # @return [Ably::Util::SafeDeferrable] 10 | # 11 | def enqueue_messages_on_connection(client, raw_messages, channel_name, channel_options = {}) 12 | messages = Array(raw_messages).map do |raw_msg| 13 | create_message(client, raw_msg, channel_options).tap do |message| 14 | next if message.client_id.nil? 15 | if message.client_id == '*' 16 | raise Ably::Exceptions::IncompatibleClientId.new('Wildcard client_id is reserved and cannot be used when publishing messages') 17 | end 18 | if message.client_id && !message.client_id.kind_of?(String) 19 | raise Ably::Exceptions::IncompatibleClientId.new('client_id must be a String when publishing messages') 20 | end 21 | unless client.auth.can_assume_client_id?(message.client_id) 22 | raise Ably::Exceptions::IncompatibleClientId.new("Cannot publish with client_id '#{message.client_id}' as it is incompatible with the current configured client_id '#{client.client_id}'") 23 | end 24 | end 25 | end 26 | 27 | max_message_size = connection.details && connection.details.max_message_size || Ably::Models::ConnectionDetails::MAX_MESSAGE_SIZE 28 | if messages.sum(&:size) > max_message_size 29 | error = Ably::Exceptions::MaxMessageSizeExceeded.new("Message size exceeded #{max_message_size} bytes.") 30 | return Ably::Util::SafeDeferrable.new_and_fail_immediately(logger, error) 31 | end 32 | 33 | connection.send_protocol_message( 34 | action: Ably::Models::ProtocolMessage::ACTION.Message.to_i, 35 | channel: channel_name, 36 | messages: messages 37 | ) 38 | 39 | if messages.count == 1 40 | # A message is a Deferrable so, if publishing only one message, simply return that Deferrable 41 | messages.first 42 | else 43 | deferrable_for_multiple_messages(messages) 44 | end 45 | end 46 | 47 | # A deferrable object that calls the success callback once all messages are delivered 48 | # If any message fails, the errback is called immediately 49 | # Only one callback or errback is ever called i.e. if a group of messages all fail, only once 50 | # errback will be invoked 51 | def deferrable_for_multiple_messages(messages) 52 | expected_deliveries = messages.count 53 | actual_deliveries = 0 54 | failed = false 55 | 56 | Ably::Util::SafeDeferrable.new(logger).tap do |deferrable| 57 | messages.each do |message| 58 | message.callback do 59 | next if failed 60 | actual_deliveries += 1 61 | deferrable.succeed messages if actual_deliveries == expected_deliveries 62 | end 63 | message.errback do |error| 64 | next if failed 65 | failed = true 66 | deferrable.fail error, message 67 | end 68 | end 69 | end 70 | end 71 | 72 | def create_message(client, message, channel_options) 73 | Ably::Models::Message(message.dup).tap do |msg| 74 | msg.encode(client.encoders, channel_options) do |encode_error, error_message| 75 | client.logger.error error_message 76 | end 77 | end 78 | end 79 | end 80 | end 81 | end 82 | 83 | -------------------------------------------------------------------------------- /lib/ably/models/device_details.rb: -------------------------------------------------------------------------------- 1 | module Ably::Modules 2 | module Conversions 3 | private 4 | # Convert device_details argument to a {Ably::Models::DeviceDetails} object 5 | # 6 | # @param device_details [Ably::Models::DeviceDetails,Hash,nil] A device details object 7 | # 8 | # @return [Ably::Models::DeviceDetails] 9 | # 10 | def DeviceDetails(device_details) 11 | case device_details 12 | when Ably::Models::DeviceDetails 13 | device_details 14 | else 15 | Ably::Models::DeviceDetails.new(device_details) 16 | end 17 | end 18 | end 19 | end 20 | 21 | module Ably::Models 22 | # Contains the properties of a device registered for push notifications. 23 | # 24 | class DeviceDetails < Ably::Exceptions::BaseAblyException 25 | include Ably::Modules::ModelCommon 26 | 27 | # @param hash_object [Hash,nil] Device detail attributes 28 | # 29 | def initialize(hash_object = {}) 30 | @raw_hash_object = hash_object || {} 31 | @hash_object = IdiomaticRubyWrapper(hash_object) 32 | end 33 | 34 | # A unique ID generated by the device. 35 | # 36 | # @spec PCD2 37 | # 38 | def id 39 | attributes[:id] 40 | end 41 | 42 | # The DevicePlatform associated with the device. 43 | # Describes the platform the device uses, such as android or ios. 44 | # 45 | # @spec PCD6 46 | # 47 | # @return [String] 48 | # 49 | def platform 50 | attributes[:platform] 51 | end 52 | 53 | # The client ID the device is connected to Ably with. 54 | # 55 | # @spec PCD3 56 | # 57 | # @return [String] 58 | # 59 | def client_id 60 | attributes[:client_id] 61 | end 62 | 63 | # The DeviceFormFactor object associated with the device. 64 | # Describes the type of the device, such as phone or tablet. 65 | # 66 | # @spec PCD4 67 | # 68 | # @return [String] 69 | # 70 | def form_factor 71 | attributes[:form_factor] 72 | end 73 | 74 | def device_secret 75 | attributes[:device_secret] 76 | end 77 | 78 | %w(id platform form_factor client_id device_secret).each do |attribute| 79 | define_method "#{attribute}=" do |val| 80 | unless val.nil? || val.kind_of?(String) 81 | raise ArgumentError, "#{attribute} must be nil or a string value" 82 | end 83 | attributes[attribute.to_sym] = val 84 | end 85 | end 86 | 87 | # A JSON object of key-value pairs that contains metadata for the device. 88 | # 89 | # @spec PCD5 90 | # 91 | # @return [Hash, nil] 92 | # 93 | def metadata 94 | attributes[:metadata] || {} 95 | end 96 | 97 | def metadata=(val) 98 | unless val.nil? || val.kind_of?(Hash) 99 | raise ArgumentError, "metadata must be nil or a Hash value" 100 | end 101 | attributes[:metadata] = val 102 | end 103 | 104 | # The {Ably::Models::DevicePushDetails} object associated with the device. 105 | # Describes the details of the push registration of the device. 106 | # 107 | # @spec PCD7 108 | # 109 | # @return [Ably::Models::DevicePushDetails] 110 | # 111 | def push 112 | DevicePushDetails(attributes[:push] || {}) 113 | end 114 | 115 | def push=(val) 116 | unless val.nil? || val.kind_of?(Hash) || val.kind_of?(Ably::Models::DevicePushDetails) 117 | raise ArgumentError, "push must be nil, a Hash value or a DevicePushDetails object" 118 | end 119 | attributes[:push] = DevicePushDetails(val) 120 | end 121 | 122 | def attributes 123 | @hash_object 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/ably/models/stats_types.rb: -------------------------------------------------------------------------------- 1 | module Ably::Models 2 | class Stats 3 | # StatsStruct is a basic Struct like class that allows methods to be defined 4 | # on the class that will be retuned co-erced objects from the underlying hash used to 5 | # initialize the object. 6 | # 7 | # This class provides a concise way to create classes that have fixed attributes and types 8 | # 9 | # @example 10 | # class MessageCount < StatsStruct 11 | # coerce_attributes :count, :data, into: Integer 12 | # end 13 | # 14 | # @api private 15 | # 16 | class StatsStruct 17 | class << self 18 | def coerce_attributes(*attributes) 19 | options = attributes.pop 20 | raise ArgumentError, 'Expected attribute into: within options hash' unless options.kind_of?(Hash) && options[:into] 21 | 22 | @type_klass = options[:into] 23 | setup_attribute_methods attributes 24 | end 25 | 26 | def type_klass 27 | @type_klass 28 | end 29 | 30 | private 31 | def setup_attribute_methods(attributes) 32 | attributes.each do |attr| 33 | define_method(attr) do 34 | # Lazy load the co-erced value only when accessed 35 | unless instance_variable_defined?("@#{attr}") 36 | instance_variable_set "@#{attr}", self.class.type_klass.new(hash[attr.to_sym]) 37 | end 38 | instance_variable_get("@#{attr}") 39 | end 40 | end 41 | end 42 | end 43 | 44 | attr_reader :hash 45 | 46 | def initialize(hash) 47 | @hash = hash || {} 48 | end 49 | end 50 | 51 | # IntegerDefaultZero will always return an Integer object and will default to value 0 unless truthy 52 | # 53 | # @api private 54 | # 55 | class IntegerDefaultZero 56 | def self.new(value) 57 | (value && value.to_i) || 0 58 | end 59 | end 60 | 61 | # MessageCount contains aggregate counts for messages and data transferred 62 | # 63 | # @spec TS5a, TS5b 64 | # 65 | class MessageCount < StatsStruct 66 | coerce_attributes :count, :data, into: IntegerDefaultZero 67 | end 68 | 69 | # RequestCount contains aggregate counts for requests made 70 | # 71 | # @spec TS8a, TS8b, TS8c 72 | # 73 | class RequestCount < StatsStruct 74 | coerce_attributes :succeeded, :failed, :refused, into: IntegerDefaultZero 75 | end 76 | 77 | # ResourceCount contains aggregate data for usage of a resource in a specific scope 78 | # 79 | class ResourceCount < StatsStruct 80 | coerce_attributes :opened, :peak, :mean, :min, :refused, into: IntegerDefaultZero 81 | end 82 | 83 | # ConnectionTypes contains a breakdown of summary stats data for different (TLS vs non-TLS) connection types 84 | # 85 | # @spec TS4a, TS4b, TS4c 86 | # 87 | class ConnectionTypes < StatsStruct 88 | coerce_attributes :tls, :plain, :all, into: ResourceCount 89 | end 90 | 91 | # MessageTypes contains a breakdown of summary stats data for different (message vs presence) message types 92 | # 93 | # @spec TS6a, TS6b, TS6c 94 | # 95 | class MessageTypes < StatsStruct 96 | coerce_attributes :messages, :presence, :all, into: MessageCount 97 | end 98 | 99 | # MessageTraffic contains a breakdown of summary stats data for traffic over various transport types 100 | # 101 | # @spec TS7a, TS7b, TS7c, TS7d 102 | # 103 | class MessageTraffic < StatsStruct 104 | coerce_attributes :realtime, :rest, :webhook, :all, into: MessageTypes 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/ably/modules/channels_collection.rb: -------------------------------------------------------------------------------- 1 | module Ably::Modules 2 | # ChannelsCollection module provides common functionality to the Rest and Realtime Channels objects 3 | # such as #get, #[], #fetch, and #release 4 | module ChannelsCollection 5 | include Enumerable 6 | 7 | def initialize(client, channel_klass) 8 | @client = client 9 | @channel_klass = channel_klass 10 | @channels = {} 11 | end 12 | 13 | # Return a Channel for the given name 14 | # 15 | # @param name [String] The name of the channel 16 | # @param channel_options [Hash, Ably::Models::ChannelOptions] A hash of options or a {Ably::Models::ChannelOptions} 17 | # 18 | # @return [Channel] 19 | # 20 | def get(name, channel_options = {}) 21 | if channels.has_key?(name) 22 | channels[name].tap do |channel| 23 | if channel_options && !channel_options.empty? 24 | if channel.respond_to?(:need_reattach?) && channel.need_reattach? 25 | raise_implicit_options_update 26 | else 27 | warn_implicit_options_update 28 | channel.options = channel_options 29 | end 30 | end 31 | end 32 | else 33 | channels[name] ||= channel_klass.new(client, name, channel_options) 34 | end 35 | end 36 | alias_method :[], :get 37 | 38 | # Return a Channel for the given name if it exists, else the block will be called. 39 | # This method is intentionally similar to {http://ruby-doc.org/core-2.1.3/Hash.html#method-i-fetch Hash#fetch} providing a simple way to check if a channel exists or not without creating one 40 | # 41 | # @param name [String] The name of the channel 42 | # 43 | # @yield [options] (optional) if a missing_block is passed to this method and no channel exists matching the name, this block is called 44 | # @yieldparam [String] name of the missing channel 45 | # 46 | # @return [Channel] 47 | # 48 | def fetch(name, &missing_block) 49 | channels.fetch(name, &missing_block) 50 | end 51 | 52 | # Destroy the Channel and releases the associated resources. 53 | # 54 | # Releasing a Channel is not typically necessary as a channel consumes no resources other than the memory footprint of the 55 | # Channel object. Explicitly release channels to free up resources if required 56 | # 57 | # @param name [String] The name of the channel 58 | # 59 | # @return [void] 60 | # 61 | def release(name) 62 | channels.delete(name) 63 | end 64 | 65 | # @!attribute [r] length 66 | # @return [Integer] number of channels created 67 | def length 68 | channels.length 69 | end 70 | alias_method :count, :length 71 | alias_method :size, :length 72 | 73 | # Method to allow {ChannelsCollection} to be {http://ruby-doc.org/core-2.1.3/Enumerable.html Enumerable} 74 | def each(&block) 75 | return to_enum(:each) unless block_given? 76 | channels.values.each(&block) 77 | end 78 | 79 | private 80 | 81 | def raise_implicit_options_update 82 | raise ArgumentError, "You are trying to indirectly update channel options which will trigger reattachment of the channel. Please use Channel#set_options directly if you wish to continue" 83 | end 84 | 85 | def warn_implicit_options_update 86 | logger.warn { "Channels#get: Using this method to update channel options is deprecated and may be removed in a future version of ably-ruby. Please use Channel#setOptions instead" } 87 | end 88 | 89 | def logger 90 | client.logger 91 | end 92 | 93 | def client 94 | @client 95 | end 96 | 97 | def channel_klass 98 | @channel_klass 99 | end 100 | 101 | def channels 102 | @channels 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/ably/models/channel_options.rb: -------------------------------------------------------------------------------- 1 | module Ably::Models 2 | # Convert token details argument to a {ChannelOptions} object 3 | # 4 | # @param attributes (see #initialize) 5 | # 6 | # @return [ChannelOptions] 7 | # 8 | def self.ChannelOptions(attributes) 9 | case attributes 10 | when ChannelOptions 11 | return attributes 12 | else 13 | ChannelOptions.new(attributes) 14 | end 15 | end 16 | 17 | # Represents options of a channel 18 | class ChannelOptions 19 | extend Ably::Modules::Enum 20 | extend Forwardable 21 | include Ably::Modules::ModelCommon 22 | 23 | # Describes the possible flags used to configure client capabilities, using {Ably::Models::ChannelOptions::MODES}. 24 | # 25 | # PRESENCE The client can enter the presence set. 26 | # PUBLISH The client can publish messages. 27 | # SUBSCRIBE The client can subscribe to messages. 28 | # PRESENCE_SUBSCRIBE The client can receive presence messages. 29 | # 30 | # @spec TB2d 31 | # 32 | MODES = ruby_enum('MODES', 33 | presence: 0, 34 | publish: 1, 35 | subscribe: 2, 36 | presence_subscribe: 3 37 | ) 38 | 39 | attr_reader :attributes 40 | 41 | alias_method :to_h, :attributes 42 | 43 | def_delegators :attributes, :fetch, :size, :empty? 44 | # Initialize a new ChannelOptions 45 | # 46 | # @spec TB3 47 | # 48 | # @option params [Hash] (TB2c) params (for realtime client libraries only) a of key/value pairs 49 | # @option modes [Hash] modes (for realtime client libraries only) an array of ChannelMode 50 | # @option cipher [Hash,Ably::Models::CipherParams] :cipher A hash of options or a {Ably::Models::CipherParams} to configure the encryption. *:key* is required, all other options are optional. 51 | # 52 | def initialize(attrs) 53 | @attributes = IdiomaticRubyWrapper(attrs.clone) 54 | 55 | attributes[:modes] = modes.to_a.map { |mode| Ably::Models::ChannelOptions::MODES[mode] } if modes 56 | attributes[:cipher] = Ably::Models::CipherParams(cipher) if cipher 57 | attributes.clone 58 | end 59 | 60 | # Requests encryption for this channel when not null, and specifies encryption-related parameters (such as algorithm, 61 | # chaining mode, key length and key). See an example. 62 | # 63 | # @spec RSL5a, TB2b 64 | # 65 | # @return [CipherParams] 66 | # 67 | def cipher 68 | attributes[:cipher] 69 | end 70 | 71 | # Channel Parameters that configure the behavior of the channel. 72 | # 73 | # @spec TB2c 74 | # 75 | # @return [Hash] 76 | # 77 | def params 78 | attributes[:params].to_h 79 | end 80 | 81 | # An array of {Ably:Models:ChannelOptions::MODES} objects. 82 | # 83 | # @spec TB2d 84 | # 85 | # @return [Array] 86 | # 87 | def modes 88 | attributes[:modes] 89 | end 90 | 91 | # Converts modes to a bitfield that coresponds to ProtocolMessage#flags 92 | # 93 | # @return [Integer] 94 | # 95 | def modes_to_flags 96 | modes.map { |mode| Ably::Models::ProtocolMessage::ATTACH_FLAGS_MAPPING[mode.to_sym] }.reduce(:|) 97 | end 98 | 99 | # @return [Hash] 100 | # @api private 101 | def set_params(hash) 102 | attributes[:params] = hash 103 | end 104 | 105 | # Sets modes from ProtocolMessage#flags 106 | # 107 | # @return [Array] 108 | # @api private 109 | def set_modes_from_flags(flags) 110 | return unless flags 111 | 112 | message_modes = MODES.select do |mode| 113 | flag = Ably::Models::ProtocolMessage::ATTACH_FLAGS_MAPPING[mode.to_sym] 114 | flags & flag == flag 115 | end 116 | 117 | attributes[:modes] = message_modes.map { |mode| Ably::Models::ChannelOptions::MODES[mode] } 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/ably/realtime/push/device_registrations.rb: -------------------------------------------------------------------------------- 1 | module Ably::Realtime 2 | class Push 3 | # Manage device registrations for push notifications 4 | class DeviceRegistrations 5 | include Ably::Modules::Conversions 6 | include Ably::Modules::AsyncWrapper 7 | 8 | # @api private 9 | attr_reader :client 10 | 11 | # @api private 12 | attr_reader :admin 13 | 14 | def initialize(admin) 15 | @admin = admin 16 | @client = admin.client 17 | end 18 | 19 | # (see Ably::Rest::Push::DeviceRegistrations#get) 20 | # 21 | # @yield Block is invoked when request succeeds 22 | # @return [Ably::Util::SafeDeferrable] 23 | # 24 | def get(device_id, &callback) 25 | device_id = device_id.id if device_id.kind_of?(Ably::Models::DeviceDetails) 26 | raise ArgumentError, "device_id must be a string or DeviceDetails object" unless device_id.kind_of?(String) 27 | 28 | async_wrap(callback) do 29 | rest_device_registrations.get(device_id) 30 | end 31 | end 32 | 33 | # (see Ably::Rest::Push::DeviceRegistrations#list) 34 | # 35 | # @yield Block is invoked when request succeeds 36 | # @return [Ably::Util::SafeDeferrable] 37 | # 38 | def list(params = {}, &callback) 39 | params = {} if params.nil? 40 | raise ArgumentError, "params must be a Hash" unless params.kind_of?(Hash) 41 | raise ArgumentError, "device_id filter cannot be specified alongside a client_id filter. Use one or the other" if params[:client_id] && params[:device_id] 42 | 43 | async_wrap(callback) do 44 | rest_device_registrations.list(params.merge(async_blocking_operations: true)) 45 | end 46 | end 47 | 48 | # (see Ably::Rest::Push::DeviceRegistrations#save) 49 | # 50 | # @yield Block is invoked when request succeeds 51 | # @return [Ably::Util::SafeDeferrable] 52 | # 53 | def save(device, &callback) 54 | device_details = DeviceDetails(device) 55 | raise ArgumentError, "Device ID is required yet is empty" if device_details.id.nil? || device_details == '' 56 | 57 | async_wrap(callback) do 58 | rest_device_registrations.save(device_details) 59 | end 60 | end 61 | 62 | # (see Ably::Rest::Push::DeviceRegistrations#remove) 63 | # 64 | # @yield Block is invoked when request succeeds 65 | # @return [Ably::Util::SafeDeferrable] 66 | # 67 | def remove(device_id, &callback) 68 | device_id = device_id.id if device_id.kind_of?(Ably::Models::DeviceDetails) 69 | raise ArgumentError, "device_id must be a string or DeviceDetails object" unless device_id.kind_of?(String) 70 | 71 | async_wrap(callback) do 72 | rest_device_registrations.remove(device_id) 73 | end 74 | end 75 | 76 | # (see Ably::Rest::Push::DeviceRegistrations#remove_where) 77 | # 78 | # @yield Block is invoked when request succeeds 79 | # @return [Ably::Util::SafeDeferrable] 80 | # 81 | def remove_where(params = {}, &callback) 82 | filter = if params.kind_of?(Ably::Models::DeviceDetails) 83 | { 'deviceId' => params.id } 84 | else 85 | raise ArgumentError, "params must be a Hash" unless params.kind_of?(Hash) 86 | raise ArgumentError, "device_id filter cannot be specified alongside a client_id filter. Use one or the other" if params[:client_id] && params[:device_id] 87 | IdiomaticRubyWrapper(params).as_json 88 | end 89 | 90 | async_wrap(callback) do 91 | rest_device_registrations.remove_where(filter) 92 | end 93 | end 94 | 95 | private 96 | def rest_device_registrations 97 | client.rest_client.push.admin.device_registrations 98 | end 99 | 100 | def logger 101 | client.logger 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/ably/models/push_channel_subscription.rb: -------------------------------------------------------------------------------- 1 | module Ably::Modules 2 | module Conversions 3 | private 4 | # Convert push_channel_subscription argument to a {Ably::Models::PushChannelSubscription} object 5 | # 6 | # @param push_channel_subscription [Ably::Models::PushChannelSubscription,Hash,nil] A device details object 7 | # 8 | # @return [Ably::Models::PushChannelSubscription] 9 | # 10 | def PushChannelSubscription(push_channel_subscription) 11 | case push_channel_subscription 12 | when Ably::Models::PushChannelSubscription 13 | push_channel_subscription 14 | else 15 | Ably::Models::PushChannelSubscription.new(push_channel_subscription) 16 | end 17 | end 18 | end 19 | end 20 | 21 | module Ably::Models 22 | # Contains the subscriptions of a device, or a group of devices sharing the same clientId, 23 | # has to a channel in order to receive push notifications. 24 | # 25 | class PushChannelSubscription < Ably::Exceptions::BaseAblyException 26 | include Ably::Modules::ModelCommon 27 | 28 | # @param hash_object [Hash,nil] Device detail attributes 29 | # 30 | def initialize(hash_object = {}) 31 | @raw_hash_object = hash_object || {} 32 | @hash_object = IdiomaticRubyWrapper(hash_object) 33 | 34 | if !attributes[:client_id] && !attributes[:device_id] 35 | raise ArgumentError, 'Either client_id or device_id must be provided' 36 | end 37 | if attributes[:client_id] && attributes[:device_id] 38 | raise ArgumentError, 'client_id and device_id cannot both be provided, they are mutually exclusive' 39 | end 40 | if !attributes[:channel] 41 | raise ArgumentError, 'channel is required' 42 | end 43 | end 44 | 45 | # A static factory method to create a PushChannelSubscription object for a channel and single device. 46 | # 47 | # @spec PSC5 48 | # 49 | # @param channel [String] the realtime pub/sub channel this subscription is registered to 50 | # @param device_id [String] Unique device identifier assigned to the push device 51 | # 52 | # @return [PushChannelSubscription] 53 | # 54 | def self.for_device(channel, device_id) 55 | PushChannelSubscription.new(channel: channel, device_id: device_id) 56 | end 57 | 58 | # A static factory method to create a PushChannelSubscription object for a channel and group of devices sharing the same clientId. 59 | # 60 | # @spec PSC5 61 | # 62 | # @param channel [String] the realtime pub/sub channel this subscription is registered to 63 | # @param client_id [String] Client ID that is assigned to one or more registered push devices 64 | # 65 | # @return [PushChannelSubscription] 66 | # 67 | def self.for_client_id(channel, client_id) 68 | PushChannelSubscription.new(channel: channel, client_id: client_id) 69 | end 70 | 71 | # The channel the push notification subscription is for. 72 | # 73 | # @spec PCS4 74 | # 75 | # @return [String] 76 | # 77 | def channel 78 | attributes[:channel] 79 | end 80 | 81 | # The ID of the client the device, or devices are associated to. 82 | # 83 | # @spec PCS3, PCS6 84 | # 85 | # @return [String] 86 | # 87 | def client_id 88 | attributes[:client_id] 89 | end 90 | 91 | # The unique ID of the device. 92 | # 93 | # @spec PCS2, PCS5, PCS6 94 | # 95 | # @return [String] 96 | # 97 | def device_id 98 | attributes[:device_id] 99 | end 100 | 101 | %w(channel client_id device_id).each do |attribute| 102 | define_method "#{attribute}=" do |val| 103 | unless val.nil? || val.kind_of?(String) 104 | raise ArgumentError, "#{attribute} must be nil or a string value" 105 | end 106 | attributes[attribute.to_sym] = val 107 | end 108 | end 109 | 110 | def attributes 111 | @hash_object 112 | end 113 | end 114 | end 115 | --------------------------------------------------------------------------------