twitter_friendly 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "activesupport", "~> 4.2.10"
4
+
5
+ gemspec path: "../"
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "activesupport", "~> 5.1.5"
4
+
5
+ gemspec path: "../"
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "activesupport", "~> 5.2.1"
4
+
5
+ gemspec path: "../"
@@ -1,4 +1,16 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext'
3
+ require 'twitter'
4
+
1
5
  require "twitter_friendly/version"
6
+ require "twitter_friendly/utils"
7
+ require "twitter_friendly/logger"
8
+ require "twitter_friendly/log_subscriber"
9
+ require "twitter_friendly/serializer"
10
+ require "twitter_friendly/cache_key"
11
+ require "twitter_friendly/cache"
12
+ require "twitter_friendly/rate_limit"
13
+ require "twitter_friendly/client"
2
14
 
3
15
  module TwitterFriendly
4
16
  class Error < StandardError; end
@@ -0,0 +1,48 @@
1
+ require 'forwardable'
2
+ require 'fileutils'
3
+
4
+ module TwitterFriendly
5
+ class Cache
6
+ extend Forwardable
7
+ def_delegators :@client, :clear, :cleanup
8
+
9
+ def initialize(*args)
10
+ options = args.extract_options!
11
+
12
+ path = options[:cache_dir] || File.join('.twitter_friendly', 'cache')
13
+ FileUtils.mkdir_p(path) unless File.exists?(path)
14
+ @client = ::ActiveSupport::Cache::FileStore.new(path, expires_in: 1.hour, race_condition_ttl: 5.minutes)
15
+ end
16
+
17
+ def fetch(method, user, options = {}, &block)
18
+ key = CacheKey.gen(method, user, options.except(:args))
19
+ super_operation = options[:args].length >= 2 && options[:args][1][:super_operation]
20
+
21
+ block_result = nil
22
+ blk =
23
+ Proc.new do
24
+ block_result = yield
25
+ encode(block_result, args: options[:args])
26
+ end
27
+
28
+ fetch_result =
29
+ if super_operation
30
+ @client.fetch(key, tf_super_operation: super_operation, &blk)
31
+ else
32
+ @client.fetch(key, &blk)
33
+ end
34
+
35
+ block_result ? block_result : decode(fetch_result, args: options[:args])
36
+ end
37
+
38
+ private
39
+
40
+ def encode(obj, options)
41
+ Serializer.encode(obj, options)
42
+ end
43
+
44
+ def decode(str, options)
45
+ Serializer.decode(str, options)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,64 @@
1
+ require 'digest/md5'
2
+
3
+ module TwitterFriendly
4
+ class CacheKey
5
+ DELIM = ':'
6
+ VERSION = '1'
7
+
8
+ class << self
9
+ def gen(method, user, options = {})
10
+ [version,
11
+ method,
12
+ method_identifier(method, user, options),
13
+ options_identifier(method, options)
14
+ ].compact.join(DELIM)
15
+ end
16
+
17
+ private
18
+
19
+ def version
20
+ 'v' + VERSION
21
+ end
22
+
23
+ def method_identifier(method, user, options)
24
+ case
25
+ when method == :search then "query#{DELIM}#{user}"
26
+ when method == :friendship? then "from#{DELIM}#{user[0]}#{DELIM}to#{DELIM}#{user[1]}"
27
+ when method == :list_members then "list_id#{DELIM}#{user}"
28
+ when method == :collect_with_max_id then method_identifier(options[:super_operation], user, options)
29
+ when method == :collect_with_cursor then method_identifier(options[:super_operation], user, options)
30
+ when user.nil? && options[:hash].present? then "token-hash#{DELIM}#{options[:hash]}"
31
+ else user_identifier(user)
32
+ end
33
+ end
34
+
35
+ def user_identifier(user)
36
+ case
37
+ when user.kind_of?(Integer) then "id#{DELIM}#{user}"
38
+ when user.kind_of?(String) then "screen_name#{DELIM}#{user}"
39
+ when user.kind_of?(Array) && user.empty? then 'The_#users_is_called_with_an_empty_array'
40
+ when user.kind_of?(Array) && user[0].kind_of?(Integer) then "ids#{DELIM}#{user.size}-#{hexdigest(user)}"
41
+ when user.kind_of?(Array) && user[0].kind_of?(String) then "screen_names#{DELIM}#{user.size}-#{hexdigest(user)}"
42
+ else raise "#{__method__}: No matches #{user.inspect}"
43
+ end
44
+ end
45
+
46
+ def options_identifier(method, options)
47
+ # TODO 内部的な値はすべてprefix _tf_ をつける
48
+ opt = options.except(:hash, :call_count, :call_limit, :super_operation, :super_super_operation, :recursive, :parallel)
49
+ opt[:in] = options[:super_operation] if %i(collect_with_max_id collect_with_cursor).include?(method)
50
+
51
+ if opt.empty?
52
+ nil
53
+ else
54
+ str = opt.map {|k, v| "#{k}=#{v}"}.join('&')
55
+ "options#{DELIM}#{str}"
56
+ end
57
+ end
58
+
59
+ def hexdigest(ary)
60
+ Digest::MD5.hexdigest(ary.join(','))
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,86 @@
1
+ module TwitterFriendly
2
+ module Caching
3
+ %i(
4
+ verify_credentials
5
+ user?
6
+ user
7
+ users
8
+ blocked_ids
9
+ friendship?
10
+ ).each do |name|
11
+ define_method(name) do |*args|
12
+ options = args.extract_options!
13
+ Instrumenter.start_processing(name, options)
14
+
15
+ Instrumenter.complete_processing(name, options) do
16
+ do_request =
17
+ Proc.new {Instrumenter.perform_request(name, options) {options.empty? ? super(*args) : super(*args, options)}}
18
+
19
+ if Utils.cache_disabled?(options)
20
+ do_request.call
21
+ else
22
+ user = (name == :friendship?) ? args[0, 2] : args[0]
23
+ @cache.fetch(name, user, options.merge(args: [name, options], hash: credentials_hash), &do_request)
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ # これらのメソッドは、内部で呼ぶメソッドをキャッシュするため、
30
+ # 全体をキャッシュすることはない。
31
+ %i(
32
+ friend_ids
33
+ follower_ids
34
+ friends
35
+ followers
36
+ friend_ids_and_follower_ids
37
+ friends_and_followers
38
+ search
39
+ favorites
40
+ home_timeline
41
+ user_timeline
42
+ mentions_timeline
43
+ memberships
44
+ list_members
45
+ retweeters_ids
46
+ ).each do |name|
47
+ define_method(name) do |*args|
48
+ options = args.extract_options!
49
+ Instrumenter.start_processing(name, options)
50
+
51
+ Instrumenter.complete_processing(name, options) do
52
+ options.empty? ? super(*args) : super(*args, options)
53
+ end
54
+ end
55
+ end
56
+
57
+ module Instrumenter
58
+
59
+ module_function
60
+
61
+ def start_processing(operation, options)
62
+ payload = {operation: operation}.merge(options)
63
+ ::ActiveSupport::Notifications.instrument('start_processing.twitter_friendly', payload) {}
64
+ end
65
+
66
+ def complete_processing(operation, options)
67
+ payload = {operation: operation}.merge(options)
68
+ ::ActiveSupport::Notifications.instrument('complete_processing.twitter_friendly', payload) { yield(payload) }
69
+ end
70
+
71
+ def perform_request(caller, options, &block)
72
+ payload = {operation: 'request', args: [caller, options]}
73
+ ::ActiveSupport::Notifications.instrument('request.twitter_friendly', payload) { yield(payload) }
74
+ end
75
+ end
76
+
77
+ module Utils
78
+
79
+ module_function
80
+
81
+ def cache_disabled?(options)
82
+ options.is_a?(Hash) && options.has_key?(:cache) && !options[:cache]
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,52 @@
1
+ require 'forwardable'
2
+
3
+ require 'twitter_friendly/rest/api'
4
+
5
+ module TwitterFriendly
6
+ class Client
7
+ extend Forwardable
8
+ def_delegators :@twitter, :access_token, :access_token_secret, :consumer_key, :consumer_secret
9
+
10
+ include TwitterFriendly::REST::API
11
+ include TwitterFriendly::RateLimit
12
+
13
+ def initialize(*args)
14
+ options = args.extract_options!
15
+
16
+ @logger = TwitterFriendly::Logger.new(options)
17
+
18
+ unless subscriber_attached?
19
+ if @logger.level == ::Logger::DEBUG
20
+ @@subscriber_attached = true
21
+ TwitterFriendly::Logging.logger = @logger
22
+ TwitterFriendly::TFLogSubscriber.attach_to :twitter_friendly
23
+ TwitterFriendly::ASLogSubscriber.attach_to :active_support
24
+ end
25
+ end
26
+
27
+ @cache = TwitterFriendly::Cache.new(options)
28
+ @twitter = Twitter::REST::Client.new(options)
29
+ end
30
+
31
+ def cache
32
+ @cache
33
+ end
34
+
35
+ def logger
36
+ @logger
37
+ end
38
+
39
+ def internal_client
40
+ @twitter
41
+ end
42
+
43
+ def subscriber_attached?
44
+ @@subscriber_attached ||= false
45
+ end
46
+ end
47
+
48
+ def cache(cache_dir = nil)
49
+ TwitterFriendly::Cache.new(cache_dir: cache_dir)
50
+ end
51
+ module_function :cache
52
+ end
@@ -0,0 +1,109 @@
1
+ module TwitterFriendly
2
+ module Logging
3
+ def truncated_payload(payload)
4
+ return payload.inspect if !payload.has_key?(:args) || !payload[:args].is_a?(Array) || payload[:args].empty? || !payload[:args][0].is_a?(Array)
5
+
6
+ args = payload[:args].dup
7
+ args[0] =
8
+ if args[0].size > 3
9
+ "[#{args[0].take(3).join(', ')} ... #{args[0].size}]"
10
+ else
11
+ args[0].inspect
12
+ end
13
+
14
+ {args: args}.merge(payload.except(:args)).inspect
15
+ end
16
+
17
+ def nested_indent(payload)
18
+ (payload[:super_operation] ? ' ' : '') + (payload[:super_super_operation] ? ' ' : '') + (payload[:tf_super_operation] ? ' ' : '')
19
+ end
20
+
21
+ module_function
22
+
23
+ def logger
24
+ @@logger
25
+ end
26
+
27
+ def logger=(logger)
28
+ @@logger = logger
29
+ end
30
+ end
31
+
32
+ class TFLogSubscriber < ::ActiveSupport::LogSubscriber
33
+ include Logging
34
+
35
+ def start_processing(event)
36
+ payload = event.payload
37
+ name = "#{nested_indent(payload)}TF::Started #{payload.delete(:operation)}"
38
+ debug do
39
+ if payload[:super_operation]
40
+ "#{name} in #{payload[:super_operation]} at #{Time.now}"
41
+ else
42
+ "#{name} at #{Time.now}"
43
+ end
44
+ end
45
+ end
46
+
47
+ def complete_processing(event)
48
+ payload = event.payload
49
+ name = "TF::Completed #{payload.delete(:operation)} in #{event.duration.round(1)}ms"
50
+ debug do
51
+ "#{nested_indent(payload)}#{name}#{" #{truncated_payload(payload)}" unless payload.empty?}"
52
+ end
53
+ end
54
+
55
+ def twitter_friendly_any(event)
56
+ payload = event.payload
57
+ payload.delete(:name)
58
+ operation = payload.delete(:operation)
59
+ name =
60
+ if operation.to_sym == :collect
61
+ " TW::#{operation.capitalize} #{payload[:args].last[:super_operation]} in #{payload[:args][0]} (#{event.duration.round(1)}ms)"
62
+ else
63
+ " TW::#{operation.capitalize} #{payload[:args][0]} (#{event.duration.round(1)}ms)"
64
+ end
65
+ c =
66
+ if %i(encode decode).include?(operation.to_sym)
67
+ YELLOW
68
+ elsif %i(collect).include?(operation.to_sym)
69
+ BLUE
70
+ else
71
+ CYAN
72
+ end
73
+ name = color(name, c, true)
74
+ debug { " #{nested_indent(payload)}#{name}#{" #{truncated_payload(payload)}" unless payload.empty?}" }
75
+ end
76
+
77
+ %w(request encode decode collect).each do |operation|
78
+ class_eval <<-METHOD, __FILE__, __LINE__ + 1
79
+ def #{operation}(event)
80
+ event.payload[:name] = '#{operation}'
81
+ twitter_friendly_any(event)
82
+ end
83
+ METHOD
84
+ end
85
+ end
86
+
87
+ class ASLogSubscriber < ::ActiveSupport::LogSubscriber
88
+ include Logging
89
+
90
+ def cache_any(event)
91
+ payload = event.payload
92
+ operation = payload[:super_operation] == :fetch ? :fetch : payload[:name]
93
+ hit = %i(read fetch).include?(operation.to_sym) && payload[:hit]
94
+ name = " AS::#{operation.capitalize}#{' (Hit)' if hit} #{payload[:key].split(':')[1]} (#{event.duration.round(1)}ms)"
95
+ name = color(name, MAGENTA, true)
96
+ debug { "#{nested_indent(payload)}#{name} #{(payload.except(:name, :expires_in, :super_operation, :hit, :race_condition_ttl, :tf_super_operation).inspect)}" }
97
+ end
98
+
99
+ # Ignore generate and fetch_hit
100
+ %w(read write delete exist?).each do |operation|
101
+ class_eval <<-METHOD, __FILE__, __LINE__ + 1
102
+ def cache_#{operation}(event)
103
+ event.payload[:name] = '#{operation}'
104
+ cache_any(event)
105
+ end
106
+ METHOD
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,18 @@
1
+ require 'forwardable'
2
+ require 'fileutils'
3
+ require 'logger'
4
+
5
+ module TwitterFriendly
6
+ class Logger
7
+ extend Forwardable
8
+ def_delegators :@logger, :debug, :info, :warn, :level
9
+
10
+ def initialize(options = {})
11
+ path = options[:log_dir] || File.join('.twitter_friendly')
12
+ FileUtils.mkdir_p(path) unless File.exists?(path)
13
+
14
+ @logger = ::Logger.new(File.join(path, 'twitter_friendly.log'))
15
+ @logger.level = options[:log_level] || :debug
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,81 @@
1
+ module TwitterFriendly
2
+ module RateLimit
3
+ def rate_limit
4
+ RateLimit.new(perform_get('/1.1/application/rate_limit_status.json'))
5
+ rescue => e
6
+ logger.warn e.inspect
7
+ nil
8
+ end
9
+
10
+ private
11
+
12
+ def perform_get(*args)
13
+ internal_client.send(:perform_get, *args)
14
+ end
15
+
16
+ class RateLimit
17
+ def initialize(status)
18
+ @status = status
19
+ end
20
+
21
+ def resources
22
+ @status[:resources]
23
+ end
24
+
25
+ def verify_credentials
26
+ extract_remaining_and_reset_in(resources[:account][:'/account/verify_credentials'])
27
+ end
28
+
29
+ def friend_ids
30
+ extract_remaining_and_reset_in(resources[:friends][:'/friends/ids'])
31
+ end
32
+
33
+ def follower_ids
34
+ extract_remaining_and_reset_in(resources[:followers][:'/followers/ids'])
35
+ end
36
+
37
+ def users
38
+ extract_remaining_and_reset_in(resources[:users][:'/users/lookup'])
39
+ end
40
+
41
+ def friends(parallel: true)
42
+ if parallel
43
+ {friend_ids: friend_ids, users: users}
44
+ else
45
+ extract_remaining_and_reset_in(resources[:friends][:'/friends/list'])
46
+ end
47
+ end
48
+
49
+ def followers(parallel: true)
50
+ if parallel
51
+ {follower_ids: follower_ids, users: users}
52
+ else
53
+ extract_remaining_and_reset_in(resources[:followers][:'/followers/list'])
54
+ end
55
+ end
56
+
57
+ def to_h
58
+ {
59
+ verify_credentials: verify_credentials,
60
+ friend_ids: friend_ids,
61
+ follower_ids: follower_ids,
62
+ users: users
63
+ }
64
+ end
65
+
66
+ def inspect
67
+ 'verify_credentials ' + verify_credentials.inspect +
68
+ ' friend_ids ' + friend_ids.inspect +
69
+ ' follower_ids ' + follower_ids.inspect +
70
+ ' users ' + users.inspect
71
+ end
72
+
73
+ private
74
+
75
+ def extract_remaining_and_reset_in(limit)
76
+ {remaining: limit[:remaining], reset_in: (Time.at(limit[:reset]) - Time.now).round}
77
+ end
78
+ end
79
+ end
80
+ end
81
+