flamingo 0.1

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2010 Hayes Davis
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,73 @@
1
+ Flamingo
2
+ ========
3
+ Flamingo is a resque-based system for handling the Twitter Streaming API.
4
+
5
+ This is *early alpha* code. There will be a lot of change and things like tests
6
+ coming in the future. That said, it does work so give it a try if you have the
7
+ need.
8
+
9
+ Dependencies
10
+ ------------
11
+ * redis
12
+ * resque
13
+ * sinatra
14
+ * twitter-stream
15
+ * yajl-ruby
16
+
17
+ By default, the `resque` gem installs the latest 2.x `redis` gem, so if
18
+ you are using Redis 1.x, you may want to swap it out.
19
+
20
+ $ gem list | grep redis
21
+ redis (2.0.3)
22
+ $ gem remove redis --version=2.0.3 -V
23
+
24
+ $ gem install redis --version=1.0.7
25
+ $ gem list | grep redis
26
+ redis (1.0.7)
27
+
28
+ Getting Started
29
+ ---------------
30
+ 1. Install the gem
31
+ sudo gem install flamingo
32
+
33
+ 2. Create a config file (see `examples/flamingo.yml`) with at least a username and password
34
+
35
+ username: USERNAME
36
+ password: PASSWORD
37
+ stream: filter
38
+ logging:
39
+ dest: /YOUR/LOG/PATH.LOG
40
+ level: LOGLEVEL
41
+
42
+ `LOGLEVEL` is one of the following:
43
+ `DEBUG` < `INFO` < `WARN` < `ERROR` < `FATAL` < `UNKNOWN`
44
+
45
+ 3. Start the Redis server
46
+
47
+ $ redis-server
48
+
49
+ 4. Configure tracking using `flamingo` client (installed during `gem install`)
50
+
51
+ $ flamingo
52
+ >> s = Stream.get(:filter)
53
+ >> s.params[:track] = %w(FOO BAR BAZ)
54
+ >> Subscription.new('YOUR_QUEUE').save
55
+
56
+ 5. Start the Flamingo Daemon (`flamingod` installed during `gem install`)
57
+
58
+ $ flamingod -c your/config/file.yml
59
+
60
+
61
+ 6. Consume events with a resque worker
62
+
63
+ class HandleFlamingoEvent
64
+
65
+ # type: One of "tweet" or "delete"
66
+ # event: a hash of the json data from twitter
67
+ def self.perform(type,event)
68
+ # Do stuff with the data
69
+ end
70
+
71
+ end
72
+
73
+ $ QUEUE=YOUR_QUEUE rake resque:work
@@ -0,0 +1,3 @@
1
+ $LOAD_PATH.unshift File.join(File.dirname(__FILE__),'lib')
2
+ require 'flamingo'
3
+ require 'resque/tasks'
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
4
+ require 'flamingo'
5
+
6
+ include Flamingo
7
+
8
+ puts "Flamingo client #{Flamingo::VERSION}"
9
+
10
+ begin
11
+ Flamingo.configure!(ARGV[0])
12
+ rescue => e
13
+ $stderr.puts "Could not start: #{e.message}"
14
+ exit(-1)
15
+ end
16
+
17
+ ARGV.clear # Remove args so IRB doesn't try to load them
18
+
19
+ require 'irb'
20
+ IRB.start
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
4
+ require 'flamingo'
5
+
6
+ Flamingo.configure!(ARGV[0])
7
+ host,port = Flamingo.config.web.host('0.0.0.0:4711').split(":")
8
+ Flamingo::Web::Server.run! :host=>host, :port=>port.to_i
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
4
+ require 'flamingo'
5
+ require 'optparse'
6
+
7
+ puts "Starting flamingod #{Flamingo::VERSION}"
8
+
9
+ begin
10
+ options = {:daemonize => nil, :config => nil}
11
+
12
+ opts = OptionParser.new do |opts|
13
+ opts.banner = <<-EOF
14
+ Usage:
15
+ flamingod [<options>]
16
+ EOF
17
+
18
+ opts.on("-cCONFIG", "--config-file=CONFIG", "Configuration File") do |x|
19
+ options[:config_file] = x
20
+ end
21
+
22
+ opts.on("-d", "--daemonize", "Run in Background (Daemonize)") do |x|
23
+ options[:daemonize] = true
24
+ end
25
+
26
+ opts.on("-b", "--background", "Run in Background (Daemonize)") do |x|
27
+ options[:daemonize] = true
28
+ end
29
+
30
+ opts.on("-f", "--foreground", "Run in Foreground (default)") do |x|
31
+ options[:daemonize] = nil
32
+ end
33
+
34
+ opts.on("-pPID_FILE", "--pid-file=PID_FILE", "PID File (only when daemonized)") do |x|
35
+ options[:pid_file] = x
36
+ end
37
+
38
+ end
39
+
40
+ opts.parse!
41
+
42
+ Flamingo.configure!(options[:config_file])
43
+ if options[:pid_file]
44
+ Flamingo.config.pid_file = File.expand_path(options[:pid_file])
45
+ end
46
+
47
+ rescue => e
48
+ $stderr.puts "Could not start: #{e.message}"
49
+ exit(-1)
50
+ end
51
+
52
+ flamingod = Flamingo::Daemon::Flamingod.new
53
+ if options[:daemonize]
54
+ begin
55
+ pid = flamingod.run_as_daemon()
56
+ puts "flamingod process #{pid} started"
57
+ rescue => e
58
+ $stderr.puts "Could not start: #{e.message}"
59
+ exit(-2)
60
+ end
61
+ else
62
+ flamingod.run()
63
+ end
@@ -0,0 +1,15 @@
1
+ # Simple example that reads from a subscription queue and writes the events
2
+ # to STDOUT
3
+ # Usage (from this directory):
4
+ # $ QUEUE=YOUR_QUEUE rake resque:work
5
+
6
+ require 'rubygems'
7
+ require 'resque/tasks'
8
+
9
+ class HandleFlamingoEvent
10
+
11
+ def self.perform(type,event)
12
+ puts type, event
13
+ end
14
+
15
+ end
@@ -0,0 +1,10 @@
1
+ username: SCREEN_NAME
2
+ password: PASSWORD
3
+ stream: filter
4
+ logging:
5
+ dest: /your/log/file/here.log
6
+ level: INFO
7
+ redis:
8
+ host: 0.0.0.0:6379
9
+ web:
10
+ host: 0.0.0.0:4711
@@ -0,0 +1,151 @@
1
+ require 'rubygems'
2
+ require 'redis/namespace'
3
+ require 'twitter/json_stream'
4
+ require 'resque'
5
+ require 'logger'
6
+ require 'yaml'
7
+ require 'erb'
8
+ require 'cgi'
9
+ require 'active_support'
10
+ require 'sinatra/base'
11
+
12
+ require 'flamingo/version'
13
+ require 'flamingo/config'
14
+ require 'flamingo/dispatch_event'
15
+ require 'flamingo/dispatch_error'
16
+ require 'flamingo/stream_params'
17
+ require 'flamingo/stream'
18
+ require 'flamingo/subscription'
19
+ require 'flamingo/wader'
20
+ require 'flamingo/daemon/pid_file'
21
+ require 'flamingo/daemon/child_process'
22
+ require 'flamingo/daemon/dispatcher_process'
23
+ require 'flamingo/daemon/web_server_process'
24
+ require 'flamingo/daemon/wader_process'
25
+ require 'flamingo/daemon/flamingod'
26
+ require 'flamingo/logging/formatter'
27
+ require 'flamingo/web/server'
28
+
29
+ module Flamingo
30
+
31
+ class << self
32
+
33
+ def configure!(config_file=nil)
34
+ config_file = find_config_file(config_file)
35
+ @config = Flamingo::Config.load(config_file)
36
+ validate_config!
37
+ logger.info "Loaded config file from #{config_file}"
38
+ end
39
+
40
+ def config
41
+ @config
42
+ end
43
+
44
+ # PHD: Lovingly borrowed from Resque
45
+
46
+ # Accepts:
47
+ # 1. A 'hostname:port' string
48
+ # 2. A 'hostname:port:db' string (to select the Redis db)
49
+ # 3. An instance of `Redis`, `Redis::Client`, `Redis::DistRedis`,
50
+ # or `Redis::Namespace`.
51
+ def redis=(server)
52
+ case server
53
+ when String
54
+ host, port, db = server.split(':')
55
+ redis = Redis.new(:host => host, :port => port,
56
+ :thread_safe => true, :db => db)
57
+ @redis = Redis::Namespace.new(:flamingo, :redis => redis)
58
+ when Redis, Redis::Client, Redis::DistRedis
59
+ @redis = Redis::Namespace.new(:flamingo, :redis => server)
60
+ when Redis::Namespace
61
+ @redis = server
62
+ else
63
+ raise "Invalid redis configuration: #{server.inspect}"
64
+ end
65
+ end
66
+
67
+ # Returns the current Redis connection. If none has been created, will
68
+ # create a new one.
69
+ def redis
70
+ return @redis if @redis
71
+ self.redis = config.redis.host('localhost:6379')
72
+ self.redis
73
+ end
74
+
75
+ def new_logger
76
+ # determine log file location (default is root_dir/log/flamingo.log)
77
+ if valid_logging_dest?(config.logging.dest(nil))
78
+ log_dest = config.logging.dest
79
+ else
80
+ log_dest = File.join(root_dir,'log','flamingo.log')
81
+ end
82
+
83
+ # determine logging level (default is Logger::INFO)
84
+ begin
85
+ log_level = Logger.const_get(config.logging.level.upcase)
86
+ rescue
87
+ log_level = Logger::INFO
88
+ end
89
+
90
+ # create logger facility
91
+ logger = Logger.new(log_dest)
92
+ logger.level = log_level
93
+ logger.formatter = Flamingo::Logging::Formatter.new
94
+ logger
95
+ end
96
+
97
+ def logger
98
+ @logger ||= new_logger
99
+ end
100
+
101
+ private
102
+ def root_dir
103
+ File.expand_path(File.dirname(__FILE__)+'/..')
104
+ end
105
+
106
+ def new_logger
107
+ dest = config.logging.dest(nil)
108
+ if valid_logging_dest?(dest)
109
+ log_file = dest
110
+ else
111
+ log_file = File.join(root_dir,'log','flamingo.log')
112
+ end
113
+
114
+ # determine logging level (default is Logger::INFO)
115
+ begin
116
+ log_level = Logger.const_get(config.logging.level('INFO').upcase)
117
+ rescue
118
+ log_level = Logger::INFO
119
+ end
120
+
121
+ # create logger facility
122
+ logger = Logger.new(log_file)
123
+ logger.level = log_level
124
+ logger.formatter = Flamingo::Logging::Formatter.new
125
+ logger
126
+ end
127
+
128
+ def valid_logging_dest?(dest)
129
+ return false unless dest
130
+ File.writable?(File.dirname(dest))
131
+ end
132
+
133
+ def validate_config!
134
+ unless config.username(nil) && config.password(nil)
135
+ raise "The config file must be YAML formatted and contain a username and password. See examples/flamingo.yml."
136
+ end
137
+ end
138
+
139
+ def find_config_file(config_file=nil)
140
+ locations = [config_file,"./flamingo.yml","~/flamingo.yml"].compact.uniq
141
+ found = locations.find do |file|
142
+ file && File.exist?(file)
143
+ end
144
+ unless found
145
+ raise "No config file found in any of #{locations.join(",")}"
146
+ end
147
+ File.expand_path(found)
148
+ end
149
+
150
+ end
151
+ end
@@ -0,0 +1,52 @@
1
+ module Flamingo
2
+ class Config
3
+
4
+ def self.load(file)
5
+ new(YAML.load(IO.read(file)))
6
+ end
7
+
8
+ def initialize(hash={})
9
+ @data = hash
10
+ end
11
+
12
+ def method_missing(name,*args,&block)
13
+ if name.to_s =~ /(.+)=$/
14
+ @data[$1] = *args
15
+ else
16
+ value = @data[name.to_s]
17
+ if value.is_a?(Hash)
18
+ self.class.new(value)
19
+ elsif value.nil? || empty_config?(value)
20
+ if !args.empty?
21
+ # Return a default if the value isn't set and there's an argument
22
+ args.length == 1 ? args[0] : args
23
+ elsif block_given?
24
+ # Run the block to get the default value
25
+ yield
26
+ else
27
+ # Return back a config object
28
+ value = self.class.new
29
+ @data[name.to_s] = value
30
+ value
31
+ end
32
+ else
33
+ value
34
+ end
35
+ end
36
+ end
37
+
38
+ def respond_to?(name)
39
+ true
40
+ end
41
+
42
+ def empty?
43
+ @data.empty?
44
+ end
45
+
46
+ private
47
+ def empty_config?(value)
48
+ value.is_a?(self.class) && value.empty?
49
+ end
50
+
51
+ end
52
+ end
@@ -0,0 +1,16 @@
1
+ module Flamingo
2
+ module Daemon
3
+ class ChildProcess
4
+ attr_accessor :pid
5
+
6
+ def kill(sig)
7
+ Process.kill(sig,pid)
8
+ end
9
+ alias_method :signal, :kill
10
+
11
+ def start
12
+ self.pid = fork { run }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ module Flamingo
2
+ module Daemon
3
+ class DispatcherProcess < ChildProcess
4
+ def run
5
+ worker = Resque::Worker.new(:flamingo)
6
+ def worker.procline(value)
7
+ # Hack to get around resque insisting on setting the proces name
8
+ $0 = "flamingod-dispatcher"
9
+ end
10
+ Flamingo.logger.info "Starting dispatcher on pid=#{Process.pid} under pid=#{Process.ppid}"
11
+ worker.work(1) # Wait 1s between jobs
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,113 @@
1
+ module Flamingo
2
+ module Daemon
3
+ class Flamingod
4
+
5
+ def exit_signaled?
6
+ @exit_signaled
7
+ end
8
+
9
+ def exit_signaled=(val)
10
+ Flamingo.logger.info "Exit signal set to #{val}"
11
+ @exit_signaled = val
12
+ end
13
+
14
+ def start_new_wader
15
+ Flamingo.logger.info "Flamingod starting new wader"
16
+ wader = WaderProcess.new
17
+ wader.start
18
+ wader
19
+ end
20
+
21
+ def start_new_dispatcher
22
+ Flamingo.logger.info "Flamingod starting new dispatcher"
23
+ dispatcher = DispatcherProcess.new
24
+ dispatcher.start
25
+ dispatcher
26
+ end
27
+
28
+ def start_new_web_server
29
+ Flamingo.logger.info "Flamingod starting new web server"
30
+ ws = WebServerProcess.new
31
+ ws.start
32
+ ws
33
+ end
34
+
35
+ def trap_signals
36
+ trap("KILL") { terminate! }
37
+ trap("TERM") { terminate! }
38
+ trap("INT") { terminate! }
39
+ trap("USR1") { restart_wader }
40
+ end
41
+
42
+ def restart_wader
43
+ Flamingo.logger.info "Flamingod restarting wader pid=#{@wader.pid} with SIGINT"
44
+ @wader.kill("INT")
45
+ end
46
+
47
+ def signal_children(sig)
48
+ pids = (children.map {|c| c.pid}).join(",")
49
+ Flamingo.logger.info "Flamingod sending SIG#{sig} to pids=#{pids}"
50
+ children.each {|child| child.signal(sig) }
51
+ end
52
+
53
+ def terminate!
54
+ Flamingo.logger.info "Flamingod terminating"
55
+ self.exit_signaled = true
56
+ signal_children("INT")
57
+ end
58
+
59
+ def children
60
+ [@wader,@web_server] + @dispatchers
61
+ end
62
+
63
+ def start_children
64
+ Flamingo.logger.info "Flamingod starting children"
65
+ @wader = start_new_wader
66
+ @dispatchers = [start_new_dispatcher]
67
+ @web_server = start_new_web_server
68
+ end
69
+
70
+ def wait_on_children()
71
+ until exit_signaled?
72
+ child_pid = Process.wait(-1)
73
+ unless exit_signaled?
74
+ if @wader.pid == child_pid
75
+ @wader = start_new_wader
76
+ elsif @web_server.pid == child_pid
77
+ @web_server = start_new_web_server
78
+ elsif (to_delete = @dispatchers.find{|d| d.pid == child_pid})
79
+ @dispatchers.delete(to_delete)
80
+ @dispatchers << start_new_dispatcher
81
+ else
82
+ Flamingo.logger.info "Received exit from unknown child #{child_pid}"
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ def run_as_daemon
89
+ pid_file = PidFile.new
90
+ if pid_file.running?
91
+ raise "flamingod process #{pid_file.read} appears to be running"
92
+ end
93
+ pid = fork do
94
+ pid_file.write(Process.pid)
95
+ [$stdout,$stdin,$stderr].each do |io|
96
+ io.reopen '/dev/null' rescue nil
97
+ end
98
+ run
99
+ pid_file.delete
100
+ end
101
+ Process.detach(pid)
102
+ pid
103
+ end
104
+
105
+ def run
106
+ $0 = 'flamingod'
107
+ trap_signals
108
+ start_children
109
+ wait_on_children
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,48 @@
1
+ module Flamingo
2
+ module Daemon
3
+ class PidFile
4
+
5
+ def read
6
+ File.read(file).strip rescue nil
7
+ end
8
+
9
+ def exists?
10
+ File.exist?(file) rescue false
11
+ end
12
+
13
+ def running?
14
+ #PHD The code below borrowed from the daemons gem
15
+ return false unless exists?
16
+ # Check if process is in existence
17
+ # The simplest way to do this is to send signal '0'
18
+ # (which is a single system call) that doesn't actually
19
+ # send a signal
20
+ begin
21
+ Process.kill(0, pid)
22
+ return true
23
+ rescue Errno::ESRCH
24
+ return false
25
+ rescue ::Exception # for example on EPERM (process exists but does not belong to us)
26
+ return true
27
+ end
28
+ end
29
+
30
+ def delete
31
+ File.delete(file) if file
32
+ end
33
+
34
+ def write(pid)
35
+ File.open(file,"w") {|f| f.write("#{pid}\n") }
36
+ true
37
+ rescue
38
+ false
39
+ end
40
+
41
+ private
42
+ def file
43
+ Flamingo.config.pid_file(nil)
44
+ end
45
+
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,28 @@
1
+ module Flamingo
2
+ module Daemon
3
+ class WaderProcess < ChildProcess
4
+ def register_signal_handlers
5
+ trap("INT") { stop }
6
+ end
7
+
8
+ def run
9
+ register_signal_handlers
10
+ $0 = 'flamingod-wader'
11
+ config = Flamingo.config
12
+
13
+ screen_name = config.username
14
+ password = config.password
15
+ stream = Stream.get(config.stream)
16
+
17
+ @wader = Flamingo::Wader.new(screen_name,password,stream)
18
+ Flamingo.logger.info "Starting wader on pid=#{Process.pid} under pid=#{Process.ppid}"
19
+ @wader.run
20
+ Flamingo.logger.info "Wader pid=#{Process.pid} stopped"
21
+ end
22
+
23
+ def stop
24
+ @wader.stop
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,12 @@
1
+ module Flamingo
2
+ module Daemon
3
+ class WebServerProcess < ChildProcess
4
+ def run
5
+ $0 = 'flamingod-web'
6
+ host, port = Flamingo.config.web.host('0.0.0.0:4711').split(":")
7
+ Flamingo::Web::Server.run! :host=>host, :port=>port.to_i,
8
+ :daemon_pid=>Process.ppid
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ module Flamingo
2
+ class DispatchError
3
+
4
+ @queue = :flamingo
5
+
6
+ def self.perform(type,message,data)
7
+ Flamingo.logger.info("#{type}, #{message}, #{data.inspect}\n")
8
+ end
9
+
10
+ end
11
+ end
@@ -0,0 +1,40 @@
1
+ module Flamingo
2
+ class DispatchEvent
3
+
4
+ @queue = :flamingo
5
+ @parser = Yajl::Parser.new(:symbolize_keys => true)
6
+
7
+ class << self
8
+
9
+ def perform(event_json)
10
+ #TODO Track stats including: tweets per second and last tweet time
11
+ #TODO Provide some first-level check for repeated status ids
12
+ #TODO Consider subscribers for receiving particular terms - do the heavy
13
+ # lifting of parsing tweets and delivering them to particular subscribers
14
+ #TODO Consider window of tweets (approx 3 seconds) and sort before
15
+ # dispatching to improve in-order delivery (helps with "k-sorted")
16
+ type, event = typed_event(parse(event_json))
17
+ # Flamingo.logger.info Flamingo.router.destinations(type,event).inspect
18
+ Subscription.all.each do |sub|
19
+ Resque::Job.create(sub.name, "HandleFlamingoEvent", type, event)
20
+ Flamingo.logger.debug "Put job on subscription queue #{sub.name} for #{event_json}"
21
+ end
22
+ end
23
+
24
+ def parse(json)
25
+ @parser.parse(json)
26
+ end
27
+
28
+ def typed_event(event)
29
+ if event[:delete]
30
+ [:delete,event[:delete]]
31
+ elsif event[:link]
32
+ [:link,event[:link]]
33
+ else
34
+ [:tweet,event]
35
+ end
36
+ end
37
+
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,12 @@
1
+ module Flamingo
2
+ module Logging
3
+ class Formatter < Logger::Formatter
4
+ def call(severity, time, progname, msg)
5
+ entry = "\n[#{time.utc.strftime("%Y-%m-%d %H:%M:%S")}, #{severity}"
6
+ entry << ", #{progname}" if progname
7
+ entry << "] - #{msg2str(msg)}"
8
+ entry
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,62 @@
1
+ module Flamingo
2
+
3
+ class Stream
4
+
5
+ VERSION = 1
6
+
7
+ RESOURCES = HashWithIndifferentAccess.new(
8
+ :filter => "statuses/filter",
9
+ :firehose => "statuses/firehose",
10
+ :retweet => "statuses/retweet",
11
+ :sample => "statuses/sample"
12
+ )
13
+
14
+ class << self
15
+ def get(name)
16
+ new(name,StreamParams.new(name))
17
+ end
18
+ end
19
+
20
+ attr_accessor :name, :params
21
+
22
+ def initialize(name,params)
23
+ self.name = name
24
+ self.params = params
25
+ end
26
+
27
+ def connect(options)
28
+ conn_opts = {:ssl => true, :user_agent => "Flamingo/0.1" }.
29
+ merge(options).merge(:path=>path)
30
+ Twitter::JSONStream.connect(conn_opts)
31
+ end
32
+
33
+ def path
34
+ "/#{VERSION}/#{resource}.json?#{query}"
35
+ end
36
+
37
+ def resource
38
+ RESOURCES[name.to_sym]
39
+ end
40
+
41
+ def to_json
42
+ ActiveSupport::JSON.encode(
43
+ :name=>name,:resource=>resource,:params=>params.all
44
+ )
45
+ end
46
+
47
+ private
48
+ def query
49
+ params.map{|key,value| "#{key}=#{param_value(value)}" }.join("&")
50
+ end
51
+
52
+ def param_value(val)
53
+ case val
54
+ when String then CGI.escape(val)
55
+ when Array then val.map{|v| CGI.escape(v) }.join(",")
56
+ else nil
57
+ end
58
+ end
59
+
60
+ end
61
+
62
+ end
@@ -0,0 +1,74 @@
1
+ module Flamingo
2
+
3
+ class StreamParams
4
+
5
+ include Enumerable
6
+
7
+ attr_accessor :stream_name
8
+
9
+ def initialize(stream_name)
10
+ self.stream_name = stream_name
11
+ end
12
+
13
+ def set(key,*values)
14
+ delete(key)
15
+ add(key,*values)
16
+ end
17
+
18
+ def []=(key,values)
19
+ values = [values] unless values.is_a?(Array)
20
+ set(key,*values)
21
+ end
22
+
23
+ def add(key,*values)
24
+ values.each do |value|
25
+ Flamingo.redis.sadd redis_key(key), value
26
+ end
27
+ end
28
+
29
+ def remove(key,*values)
30
+ values.each do |value|
31
+ Flamingo.redis.srem redis_key(key), value
32
+ end
33
+ end
34
+
35
+ def delete(key)
36
+ Flamingo.redis.del redis_key(key)
37
+ end
38
+
39
+ def get(key)
40
+ Flamingo.redis.smembers redis_key(key)
41
+ end
42
+ alias_method :[], :get
43
+
44
+ def keys
45
+ Flamingo.redis.keys(redis_key_pattern).map do |key|
46
+ key.split("?")[1].to_sym
47
+ end
48
+ end
49
+
50
+ def all
51
+ keys.inject({}) do |h,key|
52
+ h[key] = get(key)
53
+ h
54
+ end
55
+ end
56
+
57
+ def each
58
+ keys.each do |key|
59
+ yield(key,get(key))
60
+ end
61
+ end
62
+
63
+ private
64
+ def redis_key_pattern
65
+ "streams/#{stream_name}?*"
66
+ end
67
+
68
+ def redis_key(key)
69
+ "streams/#{stream_name}?#{key}"
70
+ end
71
+
72
+ end
73
+
74
+ end
@@ -0,0 +1,39 @@
1
+ module Flamingo
2
+
3
+ class Subscription
4
+
5
+ class << self
6
+
7
+ def all
8
+ Flamingo.redis.smembers("subscriptions").map do |name|
9
+ new(name)
10
+ end
11
+ end
12
+
13
+ def find(name)
14
+ if Flamingo.redis.sismember("subscriptions",name)
15
+ Subscription.new(name)
16
+ end
17
+ end
18
+
19
+ end
20
+
21
+ attr_accessor :name
22
+
23
+ def initialize(name)
24
+ self.name = name
25
+ end
26
+
27
+ def save
28
+ Flamingo.logger.info("Adding #{name} to subscriptions")
29
+ Flamingo.redis.sadd("subscriptions",name)
30
+ end
31
+
32
+ def delete
33
+ Flamingo.logger.info("Removing #{name} from subscriptions")
34
+ Flamingo.redis.srem("subscriptions",name)
35
+ end
36
+
37
+ end
38
+
39
+ end
@@ -0,0 +1,3 @@
1
+ module Flamingo
2
+ Version = VERSION = '0.1'
3
+ end
@@ -0,0 +1,57 @@
1
+ module Flamingo
2
+ class Wader
3
+
4
+ attr_accessor :screen_name, :password, :stream, :connection
5
+
6
+ def initialize(screen_name,password,stream)
7
+ self.screen_name = screen_name
8
+ self.password = password
9
+ self.stream = stream
10
+ end
11
+
12
+ def run
13
+ EventMachine::run do
14
+ self.connection = stream.connect(:auth=>"#{screen_name}:#{password}")
15
+ Flamingo.logger.info("Listening on stream: #{stream.path}")
16
+
17
+ connection.each_item do |event_json|
18
+ dispatch_event(event_json)
19
+ end
20
+
21
+ connection.on_error do |message|
22
+ dispatch_error(:generic,message)
23
+ end
24
+
25
+ connection.on_reconnect do |timeout, retries|
26
+ dispatch_error(:reconnection,
27
+ "Will reconnect after #{timeout}. Retry \##{retries}",
28
+ {:timeout=>timeout,:retries=>retries}
29
+ )
30
+ end
31
+
32
+ connection.on_max_reconnects do |timeout, retries|
33
+ dispatch_error(:fatal,
34
+ "Failed to reconnect after #{retries} retries",
35
+ {:timeout=>timeout,:retries=>retries}
36
+ )
37
+ end
38
+ end
39
+ end
40
+
41
+ def stop
42
+ connection.stop
43
+ EM.stop
44
+ end
45
+
46
+ private
47
+ def dispatch_event(event_json)
48
+ Flamingo.logger.debug "Wader dispatched event"
49
+ Resque.enqueue(Flamingo::DispatchEvent,event_json)
50
+ end
51
+
52
+ def dispatch_error(type,message,data={})
53
+ Flamingo.logger.error "Received error: #{message}"
54
+ Resque.enqueue(Flamingo::DispatchError,type,message,data)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,129 @@
1
+ module Flamingo
2
+ module Web
3
+ class Server < Sinatra::Base
4
+
5
+ set :root, File.expand_path(File.dirname(__FILE__))
6
+ set :static, true
7
+ set :logging, true
8
+
9
+ get '/' do
10
+ content_type 'text/plain'
11
+ api = self.methods.select do |method|
12
+ (method =~ /^(GET|POST) /) && !(method =~ /png$/)
13
+ end
14
+ api.sort.join("\n")
15
+ end
16
+
17
+ get '/streams/:name.json' do
18
+ stream = Stream.get(params[:name])
19
+ to_json(
20
+ :name=>stream.name,
21
+ :resource=>stream.resource,
22
+ :params=>stream.params.all
23
+ )
24
+ end
25
+
26
+ # Usage:
27
+ # streams/filter?track=a,b,c&follow=d,e,f
28
+ put '/streams/:name.json' do
29
+ key = params[:key]
30
+ stream = Stream.get(params[:name])
31
+ params.keys.each do |key|
32
+ unless key.to_sym == :name
33
+ Flamingo.logger.info "Setting #{key} to #{params[key]}"
34
+ stream.params[key] = params[key].split(",")
35
+ end
36
+ end
37
+ change_predicates
38
+ to_json(
39
+ :name=>stream.name,
40
+ :resource=>stream.resource,
41
+ :params=>stream.params.all
42
+ )
43
+ end
44
+
45
+ get '/streams/:name/:key.json' do
46
+ stream = Stream.get(params[:name])
47
+ to_json(stream.params[params[:key]])
48
+ end
49
+
50
+ # One of:
51
+ # Add values to the existing key
52
+ # ?values=A,B,C
53
+ # Add and remove in a single request
54
+ # ?add=A,B&remove=C
55
+ post '/streams/:name/:key.json' do
56
+ key = params[:key]
57
+ stream = Stream.get(params[:name])
58
+ new_terms = params[:add] || params[:values]
59
+ stream.params.add(key,*new_terms.split(","))
60
+ remove_terms = params[:remove]
61
+ if remove_terms
62
+ stream.params.remove(key,*remove_terms.split(","))
63
+ end
64
+ change_predicates
65
+ to_json(stream.params[key])
66
+ end
67
+
68
+ put '/streams/:name/:key.json' do
69
+ key = params[:key]
70
+ stream = Stream.get(params[:name])
71
+ new_terms = params[:values]
72
+ stream.params[key] = new_terms.split(",")
73
+ change_predicates
74
+ to_json(stream.params[key])
75
+ end
76
+
77
+ delete '/streams/:name/:key.json' do
78
+ key = params[:key]
79
+ stream = Stream.get(params[:name])
80
+ if params[:values].blank?
81
+ stream.params.delete(key)
82
+ else
83
+ stream.params.remove(key,*params[:values].split(","))
84
+ end
85
+ change_predicates
86
+ to_json(stream.params[key])
87
+ end
88
+
89
+ #Subscriptions
90
+ get '/subscriptions.json' do
91
+ subs = Subscription.all.map do |sub|
92
+ {:name=>sub.name}
93
+ end
94
+ to_json(subs)
95
+ end
96
+
97
+ post '/subscriptions.json' do
98
+ sub = Subscription.new(params[:name])
99
+ sub.save
100
+ to_json(:name=>sub.name)
101
+ end
102
+
103
+ get '/subscriptions/:name.json' do
104
+ sub = Subscription.find(params[:name])
105
+ not_found(to_json(:error=>"Subscription does not exist")) unless sub
106
+ to_json(:name=>sub.name)
107
+ end
108
+
109
+ delete '/subscriptions/:name.json' do
110
+ sub = Subscription.find(params[:name])
111
+ not_found(to_json(:error=>"Subscription does not exist")) unless sub
112
+ sub.delete
113
+ to_json(:name=>sub.name)
114
+ end
115
+
116
+ private
117
+ def change_predicates
118
+ if options.respond_to?(:daemon_pid)
119
+ Process.kill("USR1",options.daemon_pid)
120
+ Flamingo.logger.info "Rotating wader in daemon"
121
+ end
122
+ end
123
+
124
+ def to_json(value)
125
+ ActiveSupport::JSON.encode(value)
126
+ end
127
+ end
128
+ end
129
+ end
metadata ADDED
@@ -0,0 +1,204 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flamingo
3
+ version: !ruby/object:Gem::Version
4
+ hash: 9
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 1
9
+ version: "0.1"
10
+ platform: ruby
11
+ authors:
12
+ - Hayes Davis
13
+ - Jerry Chen
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2010-07-19 00:00:00 -05:00
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: redis
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ hash: 25
30
+ segments:
31
+ - 1
32
+ - 0
33
+ - 7
34
+ version: 1.0.7
35
+ type: :runtime
36
+ version_requirements: *id001
37
+ - !ruby/object:Gem::Dependency
38
+ name: redis-namespace
39
+ prerelease: false
40
+ requirement: &id002 !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ hash: 3
46
+ segments:
47
+ - 0
48
+ - 7
49
+ - 0
50
+ version: 0.7.0
51
+ type: :runtime
52
+ version_requirements: *id002
53
+ - !ruby/object:Gem::Dependency
54
+ name: resque
55
+ prerelease: false
56
+ requirement: &id003 !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ hash: 61
62
+ segments:
63
+ - 1
64
+ - 9
65
+ - 7
66
+ version: 1.9.7
67
+ type: :runtime
68
+ version_requirements: *id003
69
+ - !ruby/object:Gem::Dependency
70
+ name: sinatra
71
+ prerelease: false
72
+ requirement: &id004 !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ hash: 63
78
+ segments:
79
+ - 0
80
+ - 9
81
+ - 2
82
+ version: 0.9.2
83
+ type: :runtime
84
+ version_requirements: *id004
85
+ - !ruby/object:Gem::Dependency
86
+ name: twitter-stream
87
+ prerelease: false
88
+ requirement: &id005 !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ hash: 19
94
+ segments:
95
+ - 0
96
+ - 1
97
+ - 4
98
+ version: 0.1.4
99
+ type: :runtime
100
+ version_requirements: *id005
101
+ - !ruby/object:Gem::Dependency
102
+ name: yajl-ruby
103
+ prerelease: false
104
+ requirement: &id006 !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ hash: 9
110
+ segments:
111
+ - 0
112
+ - 6
113
+ - 7
114
+ version: 0.6.7
115
+ type: :runtime
116
+ version_requirements: *id006
117
+ - !ruby/object:Gem::Dependency
118
+ name: activesupport
119
+ prerelease: false
120
+ requirement: &id007 !ruby/object:Gem::Requirement
121
+ none: false
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ hash: 11
126
+ segments:
127
+ - 2
128
+ - 1
129
+ - 0
130
+ version: 2.1.0
131
+ type: :runtime
132
+ version_requirements: *id007
133
+ description: " Flamingo makes it easy to wade through the Twitter Streaming API by \n handling all connectivity and resource management for you. You just tell \n it what to track and consume the information in a resque queue. \n\n Flamingo isn't a traditional ruby gem. You don't require it into your code.\n Instead, it's designed to run as a daemon like redis or mysql. It provides \n a REST interface to change the parameters sent to the Twitter Streaming \n resource. All events from the streaming API are placed on a resque job \n queue where your application can process them.\n\n"
134
+ email: hayes@appozite.com
135
+ executables:
136
+ - flamingo
137
+ - flamingod
138
+ extensions: []
139
+
140
+ extra_rdoc_files:
141
+ - LICENSE
142
+ - README.md
143
+ files:
144
+ - README.md
145
+ - Rakefile
146
+ - lib/flamingo/config.rb
147
+ - lib/flamingo/daemon/child_process.rb
148
+ - lib/flamingo/daemon/dispatcher_process.rb
149
+ - lib/flamingo/daemon/flamingod.rb
150
+ - lib/flamingo/daemon/pid_file.rb
151
+ - lib/flamingo/daemon/wader_process.rb
152
+ - lib/flamingo/daemon/web_server_process.rb
153
+ - lib/flamingo/dispatch_error.rb
154
+ - lib/flamingo/dispatch_event.rb
155
+ - lib/flamingo/logging/formatter.rb
156
+ - lib/flamingo/stream.rb
157
+ - lib/flamingo/stream_params.rb
158
+ - lib/flamingo/subscription.rb
159
+ - lib/flamingo/version.rb
160
+ - lib/flamingo/wader.rb
161
+ - lib/flamingo/web/server.rb
162
+ - lib/flamingo.rb
163
+ - bin/flamingo
164
+ - bin/flamingo-web
165
+ - bin/flamingod
166
+ - examples/flamingo.yml
167
+ - examples/Rakefile
168
+ - LICENSE
169
+ has_rdoc: true
170
+ homepage: http://github.com/hayesdavis/flamingo
171
+ licenses: []
172
+
173
+ post_install_message:
174
+ rdoc_options: []
175
+
176
+ require_paths:
177
+ - lib
178
+ required_ruby_version: !ruby/object:Gem::Requirement
179
+ none: false
180
+ requirements:
181
+ - - ">="
182
+ - !ruby/object:Gem::Version
183
+ hash: 3
184
+ segments:
185
+ - 0
186
+ version: "0"
187
+ required_rubygems_version: !ruby/object:Gem::Requirement
188
+ none: false
189
+ requirements:
190
+ - - ">="
191
+ - !ruby/object:Gem::Version
192
+ hash: 3
193
+ segments:
194
+ - 0
195
+ version: "0"
196
+ requirements: []
197
+
198
+ rubyforge_project:
199
+ rubygems_version: 1.3.7
200
+ signing_key:
201
+ specification_version: 3
202
+ summary: Flamingo is an elegant way to wade into the Twitter Streaming API.
203
+ test_files: []
204
+