From 71f736e0a26e343e3ed1bf20f488ae9b641b33bc Mon Sep 17 00:00:00 2001 From: Max Melentiev Date: Thu, 19 May 2016 16:29:01 +0300 Subject: [PATCH] Async requests with ActiveJob --- README.md | 15 ++ lib/telegram/bot.rb | 1 + lib/telegram/bot/async.rb | 138 ++++++++++++++++++ lib/telegram/bot/botan.rb | 20 ++- lib/telegram/bot/botan/client_helpers.rb | 2 +- lib/telegram/bot/client.rb | 5 + lib/telegram/bot/config_methods.rb | 20 ++- lib/telegram/bot/updates_poller.rb | 2 +- spec/support/examples/async.rb | 89 +++++++++++ spec/telegram/bot/async_spec.rb | 28 ++++ .../telegram/bot/botan/client_helpers_spec.rb | 8 + spec/telegram/bot/botan_spec.rb | 7 + spec/telegram/bot/client_spec.rb | 7 + spec/telegram/bot/config_methods_spec.rb | 94 ++++++++++++ 14 files changed, 421 insertions(+), 15 deletions(-) create mode 100644 lib/telegram/bot/async.rb create mode 100644 spec/support/examples/async.rb create mode 100644 spec/telegram/bot/async_spec.rb create mode 100644 spec/telegram/bot/config_methods_spec.rb diff --git a/README.md b/README.md index 7cf7d36..277e9e2 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Package contains: - Middleware and routes helpers for production env. - Poller with automatic source-reloader for development env. - Rake tasks to update webhook urls. +- Async requests for Telegram and/or Botan API. Let the queue adapter handle errors! Here is sample [telegram_bot_app](https://github.com/telegram-bot-rb/telegram_bot_app) with session, keyboards and inline queries. @@ -374,6 +375,20 @@ end There is no stubbing for botan clients, so don't set botan token in tests. +### Async mode + +There is built in support for async requests using ActiveJob. Without Rails +you can implement your own worker class to handle such requests. This allows: + +- Process updates very fast, without waiting for telegram and botan responses. +- Handle and retry network and other errors with queue adapter. +- ??? + +To enable this mode add `async: true` to bot's and botan's config. +For more information and custom configuration check out +[docs](http://www.rubydoc.info/github/telegram-bot-rb/telegram-bot/master/Telegram/Bot/Async) or +[source](https://github.com/telegram-bot-rb/telegram-bot/blob/master/lib/telegram/bot/async.rb). + ## Development After checking out the repo, run `bin/setup` to install dependencies. diff --git a/lib/telegram/bot.rb b/lib/telegram/bot.rb index a51694d..72f2400 100644 --- a/lib/telegram/bot.rb +++ b/lib/telegram/bot.rb @@ -27,6 +27,7 @@ module Telegram end end + autoload :Async, 'telegram/bot/async' autoload :Botan, 'telegram/bot/botan' autoload :Client, 'telegram/bot/client' autoload :ClientStub, 'telegram/bot/client_stub' diff --git a/lib/telegram/bot/async.rb b/lib/telegram/bot/async.rb new file mode 100644 index 0000000..70ba221 --- /dev/null +++ b/lib/telegram/bot/async.rb @@ -0,0 +1,138 @@ +module Telegram + module Bot + # Telegram & Botan clients can perform requests in async way with + # any job adapter (ActiveJob by default). Using Rails you don't need any + # additional configuration. However you may want to enable async requests + # by default with `async: true` in `secrets.yml`. Botan client doesn't inherit + # async setting from client and must be configured separately. + # + # telegram: + # bots: + # chat_async: + # token: secret + # async: true # enable async mode for client + # botan: botan_token # in this way botan will not be async + # botan: # in this way - it's in async mode + # token: botan_token + # async: true + # + # Without Rails To start using async requests + # initialize client with `id` kwarg and make sure the client is + # accessible via `Teletgram.bots[id]` in job worker. Or just use + # `Telegram.bots_config=` for configuration. + # + # Being in async mode `#request` enqueues job instead to perform + # http request instead of performing it directly. + # Async behavior is controlled with `#async=` writer + # and can be enabled/disabled for the block with `#async`: + # + # client = Telegram::Bot::Client.new(**config, async: true) + # client.send_message(message) + # client.async(false) { client.send_message(other_one) } + # + # It can be set with custom job class or classname. By default it defines + # job classes for every client class, inherited from ApplicationRecord, which + # can be accessed via `.default_async_job`. You can integrate it with any + # other job provider by defining a class with `.perform_later(bot_id, *args)` + # method. See Async::Job for implemetation. + module Async + module Job + class << self + def included(base) + base.singleton_class.send :attr_accessor, :client_class + end + end + + def perform(client_id, *args) + client = self.class.client_class.wrap(client_id.to_sym) + client.async(false) { client.request(*args) } + end + end + + module ClassMethods + def default_async_job + @default_async_job ||= begin + begin + ApplicationJob + rescue NameError + raise 'Define ApplicationJob class or setup #async= with custom job class' + end + klass = Class.new(ApplicationJob) { include Job } + klass.client_class = self + const_set(:AsyncJob, klass) + end + end + + # This is used in specs. + def default_async_job=(val) + @default_async_job = val + remove_const(:AsyncJob) if const_defined?(:AsyncJob, false) + end + + # Prepares argments for async job. ActiveJob doesn't support + # Symbol in argumens. Also we can encode json bodies only once here, + # so it would not be unnecessarily serialized-deserialized. + # + # This is stub method, which returns input. Every client class + # must prepare args itself. + def prepare_async_args(*args) + args + end + end + + class << self + def prepended(base) + base.extend(ClassMethods) + end + + # Transforms symbols to strings in hash values. + def prepare_hash(hash) + return hash unless hash.is_a?(Hash) + hash = hash.dup + hash.each { |key, val| hash[key] = val.to_s if val.is_a?(Symbol) } + end + end + + attr_reader :id + + def initialize(*, id: nil, async: nil, **options) + @id = id + self.async = async + super + end + + # Sets `@async` to `self.class.default_async_job` if `true` is given + # or uses given value. + # Pass custom job class to perform async calls with. + def async=(val) + @async = + case val + when true then self.class.default_async_job + when String then const_get(val) + else val + end + end + + # Returns value of `@async` if no block is given. Otherwise sets this value + # for a block. + def async(val = true) + return @async unless block_given? + begin + old_val = @async + self.async = val + yield + ensure + @async = old_val + end + end + + # Uses job if #async is set. + def request(*args) + job_class = async + return super unless job_class + raise 'Can not enqueue job without client id' unless id + job_class.perform_later(id.to_s, *self.class.prepare_async_args(*args)) + end + end + end +end diff --git a/lib/telegram/bot/botan.rb b/lib/telegram/bot/botan.rb index 34d53e9..75b3697 100644 --- a/lib/telegram/bot/botan.rb +++ b/lib/telegram/bot/botan.rb @@ -8,12 +8,17 @@ module Telegram class Error < Bot::Error; end extend Initializers + prepend Async include DebugClient class << self def by_id(id) Telegram.botans[id] end + + def prepare_async_args(method, uri, query = {}, body = nil) + [method.to_s, uri.to_s, Async.prepare_hash(query), body] + end end attr_reader :client, :token @@ -24,12 +29,11 @@ module Telegram end def track(event, uid, payload = {}) - res = http_request( - :post, - TRACK_URI, - {token: token, name: event, uid: uid}, - payload.to_json, - ) + request(:post, TRACK_URI, {name: event, uid: uid}, payload.to_json) + end + + def request(method, uri, query = {}, body = nil) + res = http_request(method, uri, query.merge(token: token), body) status = res.status return JSON.parse(res.body) if 300 > status result = JSON.parse(res.body) rescue nil # rubocop:disable RescueModifier @@ -40,6 +44,10 @@ module Telegram def http_request(method, uri, query, body) client.request(method, uri, query, body) end + + def inspect + "#<#{self.class.name}##{object_id}(#{@id})>" + end end end end diff --git a/lib/telegram/bot/botan/client_helpers.rb b/lib/telegram/bot/botan/client_helpers.rb index c44ca8d..b88efa1 100644 --- a/lib/telegram/bot/botan/client_helpers.rb +++ b/lib/telegram/bot/botan/client_helpers.rb @@ -7,7 +7,7 @@ module Telegram def initialize(*, botan: nil, **) super - @botan = Botan.wrap(botan) if botan + @botan = Botan.wrap(botan, id: id) if botan end end end diff --git a/lib/telegram/bot/client.rb b/lib/telegram/bot/client.rb index b35d216..37a0812 100644 --- a/lib/telegram/bot/client.rb +++ b/lib/telegram/bot/client.rb @@ -10,6 +10,7 @@ module Telegram autoload :TypedResponse, 'telegram/bot/client/typed_response' extend Initializers + prepend Async prepend Botan::ClientHelpers include DebugClient @@ -30,6 +31,10 @@ module Telegram body[k] = val.to_json if val.is_a?(Hash) || val.is_a?(Array) end end + + def prepare_async_args(action, body = {}) + [action.to_s, Async.prepare_hash(prepare_body(body))] + end end attr_reader :client, :token, :username, :base_uri diff --git a/lib/telegram/bot/config_methods.rb b/lib/telegram/bot/config_methods.rb index ea55ec1..6272e21 100644 --- a/lib/telegram/bot/config_methods.rb +++ b/lib/telegram/bot/config_methods.rb @@ -26,7 +26,9 @@ module Telegram # Hash of bots made with bots_config. def bots - @bots ||= bots_config.transform_values(&Client.method(:wrap)) + @bots ||= bots_config.each_with_object({}) do |(id, config), h| + h[id] = Client.wrap(config, id: id) + end end # Default bot. @@ -44,12 +46,16 @@ module Telegram # # Can be overwritten with .bots_config= def bots_config - return @bots_config if @bots_config - telegram_config = Rails.application.secrets[:telegram] - (telegram_config['bots'] || {}).symbolize_keys.tap do |config| - default = telegram_config['bot'] - config[:default] = default if default - end + @bots_config ||= + if defined?(Rails) + telegram_config = Rails.application.secrets[:telegram] || {} + (telegram_config['bots'] || {}).symbolize_keys.tap do |config| + default = telegram_config['bot'] + config[:default] = default if default + end + else + {} + end end # Resets all cached bots and their configs. diff --git a/lib/telegram/bot/updates_poller.rb b/lib/telegram/bot/updates_poller.rb index 825dcdb..05e2b15 100644 --- a/lib/telegram/bot/updates_poller.rb +++ b/lib/telegram/bot/updates_poller.rb @@ -64,7 +64,7 @@ module Telegram end def fetch_updates - response = bot.get_updates(offset: offset, timeout: timeout) + response = bot.async(false) { bot.get_updates(offset: offset, timeout: timeout) } return unless response['ok'] && response['result'].any? reload! do response['result'].each do |update| diff --git a/spec/support/examples/async.rb b/spec/support/examples/async.rb new file mode 100644 index 0000000..795b72c --- /dev/null +++ b/spec/support/examples/async.rb @@ -0,0 +1,89 @@ +RSpec.shared_examples 'async' do |request_args: -> {}| + let(:instance) { described_class.new(token: token, id: id, async: async) } + let(:id) { :default_bot } + let(:async) { true } + let!(:application_job_class) do + klass = Class.new do + def self.perform_later(*) + end + end + klass.tap { |x| stub_const('ApplicationJob', x) } + end + after { described_class.default_async_job = nil } + + describe '#async' do + subject { ->(*args, &block) { instance.async(*args, &block) } } + its(:call) { should eq described_class.default_async_job } + + context 'when async is disabled' do + let(:async) { false } + its(:call) { should eq false } + end + + context 'when using with block' do + it 'sets value inside block' do + expect do + expect do + subject.call(false) do + expect do + subject.call(nil) { expect(subject[]).to eq nil } + end.to_not change(&subject).from false + raise 'TestError' + end + end.to raise_error(/TestError/) + end.to_not change(instance, :async).from(described_class.default_async_job) + end + end + end + + describe '.default_async_job' do + subject { described_class.default_async_job } + its(:superclass) { should eq application_job_class } + it { should include Telegram::Bot::Async::Job } + its(:client_class) { should eq described_class } + + context 'when ApplicationJob is not defined' do + let(:application_job_class) {} + it { expect { subject }.to raise_error(/Define ApplicationJob/) } + end + end + + describe '#request' do + subject { ->(*args) { instance.request(*(args.empty? ? self.args : args)) } } + let(:args, &request_args) + let(:result) { double(:result) } + + shared_examples 'enqueues job' do + it 'enqueues job' do + expect(instance).to_not receive(:http_request) + expect(described_class).to receive(:prepare_async_args).with(*args) { args } + expect(instance.async).to receive(:perform_later).with(id.to_s, *args) { result } + expect(subject.call).to eq result + end + end + + include_examples 'enqueues job' + + context 'with custom job class' do + let(:async) { double(:job_class) } + include_examples 'enqueues job' + end + + context 'when id is not set' do + let(:id) {} + it { should raise_error(/Can not enqueue/) } + end + + context 'when async is disabled' do + let(:async) { false } + let(:result) { double(status: 200, body: '{"test":"ok"}') } + let(:args, &request_args) + + it 'performs request immediately' do + expect(instance).to receive(:request).with(*args).and_call_original + expect(instance).to receive(:http_request) { result } + expect(subject[]).to eq 'test' => 'ok' + end + end + end +end diff --git a/spec/telegram/bot/async_spec.rb b/spec/telegram/bot/async_spec.rb new file mode 100644 index 0000000..ce13e43 --- /dev/null +++ b/spec/telegram/bot/async_spec.rb @@ -0,0 +1,28 @@ +RSpec.describe Telegram::Bot::Async::Job do + let(:job_class) do + described_class = self.described_class + client_class = self.client_class + Class.new do + include described_class + self.client_class = client_class + end + end + let(:client_class) { Telegram::Bot::Client } + let(:instance) { job_class.new } + + describe '#perform' do + subject { instance.perform(id, *args) } + let(:id) { 'bot_id' } + let(:args) { [double(:action), {body: :content}] } + let(:client) { Telegram::Bot::Client.new(async: custom_job_class) } + let(:custom_job_class) { Class.new } + let(:result) { double(status: 200, body: '{"test":"ok"}') } + + it 'finds client and performs request' do + expect(client_class).to receive(:wrap).with(id.to_sym) { client } + expect(client).to receive(:request).with(*args).and_call_original + expect(client).to receive(:http_request) { result } + should eq 'test' => 'ok' + end + end +end diff --git a/spec/telegram/bot/botan/client_helpers_spec.rb b/spec/telegram/bot/botan/client_helpers_spec.rb index a2e3cb5..69945f4 100644 --- a/spec/telegram/bot/botan/client_helpers_spec.rb +++ b/spec/telegram/bot/botan/client_helpers_spec.rb @@ -13,6 +13,12 @@ RSpec.describe Telegram::Bot::Client do let(:client_args) { [token, id: client_id, async: Class.new, botan: botan_token] } it { should be_instance_of Telegram::Bot::Botan } its(:token) { should eq botan_token } + its(:id) { should eq client_id } + + it 'doesnt inherit async from client' do + expect(instance.async).to be + expect(subject.async).to_not be + end end context 'when botan is configured with hash' do @@ -20,6 +26,8 @@ RSpec.describe Telegram::Bot::Client do let(:botan_config) { {token: botan_token, async: Class.new} } it { should be_instance_of Telegram::Bot::Botan } its(:token) { should eq botan_token } + its(:id) { should eq client_id } + its(:async) { should eq botan_config[:async] } end end end diff --git a/spec/telegram/bot/botan_spec.rb b/spec/telegram/bot/botan_spec.rb index 809d69d..03a072b 100644 --- a/spec/telegram/bot/botan_spec.rb +++ b/spec/telegram/bot/botan_spec.rb @@ -3,6 +3,7 @@ RSpec.describe Telegram::Bot::Botan do let(:token) { 'token' } include_examples 'initializers', :botans + include_examples 'async', request_args: -> { [double(:method), double(:url)] } describe '.new' do subject { described_class.new(*args) } @@ -17,4 +18,10 @@ RSpec.describe Telegram::Bot::Botan do its(:token) { should eq args[0][:token] } end end + + describe '.prepare_async_args' do + subject { described_class.prepare_async_args(*input) } + let(:input) { [:post, :uri, {a: 1, b: :sym, 'd' => 'str'}, 'body'] } + it { should eq ['post', 'uri', {a: 1, b: 'sym', 'd' => 'str'}, 'body'] } + end end diff --git a/spec/telegram/bot/client_spec.rb b/spec/telegram/bot/client_spec.rb index 41f0b38..df4d1a8 100644 --- a/spec/telegram/bot/client_spec.rb +++ b/spec/telegram/bot/client_spec.rb @@ -4,6 +4,7 @@ RSpec.describe Telegram::Bot::Client do let(:botan_token) { double(:botan_token) } include_examples 'initializers' + include_examples 'async', request_args: -> { [double(:action), {body: :content}] } describe '.prepare_body' do subject { described_class.prepare_body(input) } @@ -24,6 +25,12 @@ RSpec.describe Telegram::Bot::Client do end end + describe '.prepare_async_args' do + subject { described_class.prepare_async_args(*input) } + let(:input) { [:action, a: 1, b: :sym, c: [:other], 'd' => 'str'] } + it { should eq ['action', a: 1, b: 'sym', c: '["other"]', 'd' => 'str'] } + end + describe '.new' do subject { described_class.new(*args) } diff --git a/spec/telegram/bot/config_methods_spec.rb b/spec/telegram/bot/config_methods_spec.rb new file mode 100644 index 0000000..f806896 --- /dev/null +++ b/spec/telegram/bot/config_methods_spec.rb @@ -0,0 +1,94 @@ +RSpec.describe Telegram::Bot::ConfigMethods do + let(:registry) do + Object.new.tap do |x| + x.extend described_class + x.bots_config = config + end + end + let(:config) do + { + default: 'default_token', + chat: { + token: 'chat_token', + username: 'Chat', + botan: 'chat_botan_token', + }, + other_chat: { + 'token' => 'other_chat_token', + 'username' => 'OtherChat', + 'botan' => 'other_chat_botan_token', + }, + } + end + + describe '#bot' do + subject { registry.bot } + it { should eq registry.bots[:default] } + end + + describe '#bots' do + context 'configured by token' do + subject { registry.bots[:default] } + its(:id) { should eq :default } + its(:token) { should eq config[:default] } + end + + context 'configured by hash' do + subject { registry.bots[:chat] } + its(:id) { should eq :chat } + its(:token) { should eq config[:chat][:token] } + its(:username) { should eq config[:chat][:username] } + its('botan.token') { should eq config[:chat][:botan] } + end + + context 'configured by hash with stringified keys' do + subject { registry.bots[:other_chat] } + its(:id) { should eq :other_chat } + its(:token) { should eq config[:other_chat]['token'] } + its(:username) { should eq config[:other_chat]['username'] } + its('botan.token') { should eq config[:other_chat]['botan'] } + end + end + + describe '#botans' do + subject { registry.botans } + it do + should eq( + default: nil, + chat: registry.bots[:chat].botan, + other_chat: registry.bots[:other_chat].botan, + ) + end + end + + describe '#bots_config' do + subject { registry.bots_config } + it { should eq config } + + context 'when not configured' do + let(:registry) { Object.new.tap { |x| x.extend described_class } } + it { should eq({}) } + + context 'in rails environment' do + before { stub_const('Rails', double(application: double(secrets: secrets))) } + let(:secrets) { {} } + it { should eq({}) } + + context 'when there is telegram section in secrets' do + let(:secrets) { {telegram: config.stringify_keys} } + let(:config) do + { + bot: double(:bot_config), + bots: { + chat: double(:chat_config), + other_chat: double(:other_chat_config), + }, + } + end + it { should include default: config[:bot] } + it { should include config[:bots] } + end + end + end + end +end