routemaster-drain 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.env.test +6 -0
  3. data/.gitignore +7 -0
  4. data/.gitmodules +3 -0
  5. data/.rspec +3 -0
  6. data/.ruby-version +1 -0
  7. data/.travis.yml +16 -0
  8. data/.yardopts +6 -0
  9. data/Gemfile +17 -0
  10. data/Gemfile.lock +122 -0
  11. data/Guardfile +10 -0
  12. data/LICENSE.txt +22 -0
  13. data/README.md +261 -0
  14. data/Rakefile +1 -0
  15. data/lib/routemaster/cache.rb +133 -0
  16. data/lib/routemaster/config.rb +57 -0
  17. data/lib/routemaster/dirty/filter.rb +49 -0
  18. data/lib/routemaster/dirty/map.rb +63 -0
  19. data/lib/routemaster/dirty/state.rb +33 -0
  20. data/lib/routemaster/drain.rb +5 -0
  21. data/lib/routemaster/drain/basic.rb +40 -0
  22. data/lib/routemaster/drain/caching.rb +43 -0
  23. data/lib/routemaster/drain/mapping.rb +43 -0
  24. data/lib/routemaster/drain/terminator.rb +31 -0
  25. data/lib/routemaster/fetcher.rb +63 -0
  26. data/lib/routemaster/jobs/cache.rb +12 -0
  27. data/lib/routemaster/jobs/cache_and_sweep.rb +16 -0
  28. data/lib/routemaster/middleware/authenticate.rb +59 -0
  29. data/lib/routemaster/middleware/cache.rb +29 -0
  30. data/lib/routemaster/middleware/dirty.rb +33 -0
  31. data/lib/routemaster/middleware/filter.rb +26 -0
  32. data/lib/routemaster/middleware/parse.rb +43 -0
  33. data/lib/routemaster/middleware/root_post_only.rb +18 -0
  34. data/lib/routemaster/redis_broker.rb +42 -0
  35. data/routemaster-drain.gemspec +28 -0
  36. data/spec/routemaster/cache_spec.rb +115 -0
  37. data/spec/routemaster/dirty/filter_spec.rb +77 -0
  38. data/spec/routemaster/dirty/map_spec.rb +122 -0
  39. data/spec/routemaster/dirty/state_spec.rb +41 -0
  40. data/spec/routemaster/drain/basic_spec.rb +37 -0
  41. data/spec/routemaster/drain/caching_spec.rb +47 -0
  42. data/spec/routemaster/drain/mapping_spec.rb +51 -0
  43. data/spec/routemaster/drain/terminator_spec.rb +61 -0
  44. data/spec/routemaster/fetcher_spec.rb +56 -0
  45. data/spec/routemaster/middleware/authenticate_spec.rb +59 -0
  46. data/spec/routemaster/middleware/cache_spec.rb +35 -0
  47. data/spec/routemaster/middleware/dirty_spec.rb +33 -0
  48. data/spec/routemaster/middleware/filter_spec.rb +35 -0
  49. data/spec/routemaster/middleware/parse_spec.rb +69 -0
  50. data/spec/routemaster/middleware/root_post_only_spec.rb +30 -0
  51. data/spec/spec_helper.rb +16 -0
  52. data/spec/support/events.rb +9 -0
  53. data/spec/support/rack_test.rb +23 -0
  54. data/spec/support/uses_dotenv.rb +11 -0
  55. data/spec/support/uses_redis.rb +15 -0
  56. data/spec/support/uses_webmock.rb +12 -0
  57. data/test.rb +17 -0
  58. metadata +247 -0
@@ -0,0 +1,31 @@
1
+ require 'wisper'
2
+
3
+ module Routemaster
4
+ module Drain
5
+ # Tiny Rack app to terminates a Routemaster middleware chain.
6
+ #
7
+ # Respond 204 if a payload has been parsed (i.e. present in the environment)
8
+ # and 400 if not.
9
+ #
10
+ # If an event payload has been placed in `env['routemaster.payload']`
11
+ # by upper middleware, broadcasts the `:events_received` event with the
12
+ # payload.
13
+ #
14
+ # Nothing will be broadcast if the payload is empty.
15
+ #
16
+ class Terminator
17
+ include Wisper::Publisher
18
+
19
+ def call(env)
20
+ payload = env['routemaster.payload']
21
+ if payload.nil?
22
+ return [400, {'Content-Type' => 'text/plain'}, 'no payload parsed']
23
+ end
24
+
25
+ publish(:events_received, payload) if payload.any?
26
+ [204, {}, []]
27
+ end
28
+ end
29
+ end
30
+ end
31
+
@@ -0,0 +1,63 @@
1
+ require 'faraday'
2
+ require 'faraday_middleware'
3
+ require 'hashie'
4
+ require 'routemaster/config'
5
+
6
+ module Routemaster
7
+ # Fetches URLs from JSON APIs.
8
+ class Fetcher
9
+ module ClassMethods
10
+ # Calls `get` with the same arguments on a memoized instance
11
+ # for the URL's host.
12
+ def get(url, params:nil, headers:nil)
13
+ _connection_for(url).get(url, params:params, headers:headers)
14
+ end
15
+
16
+ private
17
+
18
+ def _connection_for(url)
19
+ host = URI.parse(url).host
20
+ @connections ||= {}
21
+ @connections[host] ||= new(host)
22
+ end
23
+ end
24
+ extend ClassMethods
25
+
26
+ def initialize(host)
27
+ @host = host
28
+ end
29
+
30
+ # Performs a GET HTTP request for the `url`, with optional
31
+ # query parameters (`params`) and additional headers (`headers`).
32
+ #
33
+ # @return an object that responds to `status` (integer), `headers` (hash),
34
+ # and `body`. The body is a `Hashie::Mash` if the response was JSON, a
35
+ # string otherwise.
36
+ def get(url, params:nil, headers:nil)
37
+ r = _connection.get(url, params, headers)
38
+ Hashie::Mash.new(status: r.status, headers: r.headers, body: r.body)
39
+ end
40
+
41
+ private
42
+
43
+ def _connection
44
+ @_connection ||= Faraday.new do |f|
45
+ f.request :retry, max: 2, interval: 100e-3, backoff_factor: 2
46
+ f.request :basic_auth, *_uuid
47
+ f.response :mashify
48
+ f.response :json, content_type: /\bjson/
49
+ f.adapter :net_http_persistent
50
+
51
+ # TODO: make these configurable
52
+ f.options.timeout = 1.0
53
+ f.options.open_timeout = 1.0
54
+ f.ssl.verify = false
55
+ end
56
+ end
57
+
58
+ def _uuid
59
+ @_uuid ||= Config.cache_auth[@host]
60
+ end
61
+ end
62
+ end
63
+
@@ -0,0 +1,12 @@
1
+ require 'routemaster/cache'
2
+
3
+ module Routemaster
4
+ module Jobs
5
+ # Caches a URL using {Cache}.
6
+ class Cache
7
+ def self.perform(url)
8
+ Cache.new.get(url)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,16 @@
1
+ require 'routemaster/cache'
2
+ require 'routemaster/dirty/map'
3
+
4
+ module Routemaster
5
+ module Jobs
6
+ # Caches a URL using {Cache}, and sweeps the dirty map
7
+ # if sucessful.
8
+ class CacheAndSweep
9
+ def self.perform(url)
10
+ Dirty::Map.new.sweep_one(url) do
11
+ Cache.new.get(url)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,59 @@
1
+ require 'wisper'
2
+ require 'base64'
3
+ require 'routemaster/config'
4
+
5
+ module Routemaster
6
+ module Middleware
7
+ # Authenticates requests according to the Routemaster spec.
8
+ #
9
+ # Broadcasts `:authenticate` with one of `:missing`, `failed`, or
10
+ # `:succeeded`.
11
+ #
12
+ # This is very close to `Rack::Auth::Basic`, in that HTTP Basic
13
+ # is used; but the password part is ignored. In other words, this performs
14
+ # token authentication using HTTP Basic.
15
+ #
16
+ class Authenticate
17
+ include Wisper::Publisher
18
+
19
+ # @param uuid [Enumerable] a set of accepted authentication tokens
20
+ def initialize(app, uuid: nil)
21
+ @app = app
22
+ @uuid = uuid || Config.drain_tokens
23
+
24
+ unless @uuid.kind_of?(String) || @uuid.kind_of?(Enumerable)
25
+ raise ArgumentError, ':uuid must be a String or Enumerable'
26
+ end
27
+ end
28
+
29
+ def call(env)
30
+ unless _has_auth?(env)
31
+ publish(:authenticate, :missing, env)
32
+ return [401, {}, []]
33
+ end
34
+
35
+ unless _valid_auth?(env)
36
+ publish(:authenticate, :failed, env)
37
+ return [403, {}, []]
38
+ end
39
+
40
+ publish(:authenticate, :succeeded, env)
41
+ @app.call(env)
42
+ end
43
+
44
+ private
45
+
46
+ def _has_auth?(env)
47
+ env.has_key?('HTTP_AUTHORIZATION')
48
+ end
49
+
50
+ def _valid_auth?(env)
51
+ token = Base64.
52
+ decode64(env['HTTP_AUTHORIZATION'].gsub(/^Basic /, '')).
53
+ split(':').first
54
+ @uuid.include?(token)
55
+ end
56
+ end
57
+ end
58
+ end
59
+
@@ -0,0 +1,29 @@
1
+ require 'routemaster/cache'
2
+ require 'routemaster/config'
3
+ require 'routemaster/jobs/cache_and_sweep'
4
+ require 'resque'
5
+
6
+ module Routemaster
7
+ module Middleware
8
+ class Cache
9
+ def initialize(app, cache:nil, resque:nil, queue:nil)
10
+ @app = app
11
+ @cache = cache || Routemaster::Cache.new
12
+ @resque = resque || Resque
13
+ @queue = queue || Config.queue_name
14
+ end
15
+
16
+ def call(env)
17
+ env.fetch('routemaster.dirty', []).each do |url|
18
+ @cache.bust(url)
19
+ @resque.enqueue_to(@queue, Routemaster::Jobs::CacheAndSweep, url)
20
+ end
21
+ @app.call(env)
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+
28
+
29
+
@@ -0,0 +1,33 @@
1
+ require 'routemaster/dirty/map'
2
+
3
+ module Routemaster
4
+ module Middleware
5
+ # If an event payload was place in the environment
6
+ # (`env['routemaster.payload']`) by a previous middleware,
7
+ # mark each corresponding entity as dirty.
8
+ #
9
+ # All events are passed through.
10
+ #
11
+ # The dirty map is passed as `:map` to the constructor and must respond to
12
+ # `#mark` (like `Routemaster::Dirty::Map`).
13
+ class Dirty
14
+ def initialize(app, dirty_map:nil)
15
+ @app = app
16
+ @map = dirty_map || Routemaster::Dirty::Map.new
17
+ end
18
+
19
+ def call(env)
20
+ env['routemaster.dirty'] = dirty = []
21
+ env.fetch('routemaster.payload', []).each do |event|
22
+ next if event['type'] == 'noop'
23
+ next unless @map.mark(event['url'])
24
+ dirty << event['url']
25
+ end
26
+ @app.call(env)
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+
33
+
@@ -0,0 +1,26 @@
1
+ require 'routemaster/dirty/filter'
2
+
3
+ module Routemaster
4
+ module Middleware
5
+ # Filters event payloads passed in the environment (in
6
+ # `env['routemaster.payload']`), is any.
7
+ #
8
+ # Will use `Routemaster::Dirty::Filter` by default.
9
+ class Filter
10
+ # @param filter [Routemaster::Dirty::Filter] an event filter (optional;
11
+ # will be created using the `redis` and `expiry` options if not provided)
12
+ def initialize(app, filter:nil)
13
+ @app = app
14
+ @filter = filter || Routemaster::Dirty::Filter.new
15
+ end
16
+
17
+ def call(env)
18
+ payload = env['routemaster.payload']
19
+ if payload && payload.any?
20
+ env['routemaster.payload'] = @filter.run(payload)
21
+ end
22
+ @app.call(env)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,43 @@
1
+ require 'json'
2
+ require 'hashie'
3
+
4
+ module Routemaster
5
+ module Middleware
6
+ # Receives a JSON payload of Routemaster events and parses it.
7
+ #
8
+ # It also ignores anything but POST with `application/json` MIMEs.
9
+ #
10
+ # Lower middlewares (or the app) can access the parsed payload as a hash
11
+ # in +env['routemaster.payload']+
12
+ class Parse
13
+ def initialize(app)
14
+ @app = app
15
+ end
16
+
17
+ def call(env)
18
+ if env['CONTENT_TYPE'] != 'application/json'
19
+ return [415, {}, []]
20
+ end
21
+ if payload = _extract_payload(env)
22
+ env['routemaster.payload'] = payload
23
+ else
24
+ return [400, {}, []]
25
+ end
26
+ @app.call(env)
27
+ end
28
+
29
+ private
30
+
31
+ def _extract_payload(env)
32
+ data = JSON.parse(env['rack.input'].read).map { |e| Hashie::Mash.new(e) }
33
+ return nil unless data.kind_of?(Array)
34
+ return nil unless data.all? { |e| e.t && e.type && e.topic && e.url }
35
+ return data
36
+ rescue JSON::ParserError
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+
@@ -0,0 +1,18 @@
1
+ module Routemaster
2
+ module Middleware
3
+ # Rejects all requests but POST to the root path
4
+ class RootPostOnly
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ return [404, {}, []] if env['PATH_INFO'] != '/'
11
+ return [405, {}, []] if env['REQUEST_METHOD'] != 'POST'
12
+ @app.call(env)
13
+ end
14
+ end
15
+ end
16
+ end
17
+
18
+
@@ -0,0 +1,42 @@
1
+ require 'redis-namespace'
2
+ require 'uri'
3
+ require 'singleton'
4
+
5
+ module Routemaster
6
+ class RedisBroker
7
+ include Singleton
8
+
9
+ def initialize
10
+ @_connections = {}
11
+ _cleanup
12
+ end
13
+
14
+ def get(url)
15
+ _check_for_fork
16
+ @_connections[url] ||= begin
17
+ parsed_url = URI.parse(url)
18
+ namespace = parsed_url.path.split('/')[2] || 'rm'
19
+ Redis::Namespace.new(namespace, redis: Redis.new(url: url))
20
+ end
21
+ end
22
+
23
+ def cleanup
24
+ _cleanup
25
+ end
26
+
27
+ private
28
+
29
+ def _check_for_fork
30
+ return if Process.pid != @_pid
31
+ _cleanup
32
+ end
33
+
34
+ def _cleanup
35
+ @_pid = Process.pid
36
+ @_connections.each_value(&:quit)
37
+ @_connections = {}
38
+ end
39
+
40
+ end
41
+ end
42
+
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'routemaster/drain'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'routemaster-drain'
8
+ spec.version = Routemaster::Drain::VERSION
9
+ spec.authors = ['Julien Letessier']
10
+ spec.email = ['julien.letessier@gmail.com']
11
+ spec.summary = %q{Event receiver for the Routemaster bus}
12
+ spec.homepage = 'http://github.com/HouseTrip/routemaster_drain'
13
+ spec.license = 'MIT'
14
+
15
+ spec.files = `git ls-files`.split($/)
16
+ spec.test_files = spec.files.grep(%r{^spec/})
17
+ spec.require_paths = %w(lib)
18
+
19
+ spec.add_runtime_dependency 'faraday'
20
+ spec.add_runtime_dependency 'faraday_middleware'
21
+ spec.add_runtime_dependency 'net-http-persistent'
22
+ spec.add_runtime_dependency 'rack'
23
+ spec.add_runtime_dependency 'wisper'
24
+ spec.add_runtime_dependency 'hashie'
25
+ spec.add_runtime_dependency 'redis-namespace'
26
+ spec.add_runtime_dependency 'resque'
27
+ spec.add_runtime_dependency 'thread'
28
+ end
@@ -0,0 +1,115 @@
1
+ require 'spec_helper'
2
+ require 'spec/support/uses_redis'
3
+ require 'spec/support/uses_dotenv'
4
+ require 'routemaster/cache'
5
+ require 'routemaster/fetcher'
6
+
7
+ describe Routemaster::Cache do
8
+ uses_dotenv
9
+ uses_redis
10
+
11
+ let(:fetcher) { double 'fetcher' }
12
+ let(:url) { make_url(1) }
13
+ let(:listener) { double 'listener' }
14
+ subject { described_class.new(fetcher: fetcher) }
15
+
16
+ def make_url(idx)
17
+ "https://example.com/stuff/#{idx}"
18
+ end
19
+
20
+ def make_response(idx)
21
+ Hashie::Mash.new(
22
+ status: 200,
23
+ headers: {},
24
+ body: { id: 123, t: idx }
25
+ )
26
+ end
27
+
28
+ shared_examples 'a response getter' do
29
+ before do
30
+ @counter = 0
31
+ allow(fetcher).to receive(:get) do |url, **options|
32
+ make_response(@counter += 1)
33
+ end
34
+ end
35
+
36
+ it 'fetches the url' do
37
+ expect(fetcher).to receive(:get).with(url, anything)
38
+ performer.call(url).status
39
+ end
40
+
41
+ it 'passes the locale header' do
42
+ expect(fetcher).to receive(:get) do |url, **options|
43
+ expect(options[:headers]['Accept-Language']).to eq('fr')
44
+ make_response(1)
45
+ end
46
+ performer.call(url, locale: 'fr').status
47
+ end
48
+
49
+ it 'passes the version header' do
50
+ expect(fetcher).to receive(:get) do |url, **options|
51
+ expect(options[:headers]['Accept']).to eq('application/json;v=33')
52
+ make_response(1)
53
+ end
54
+ performer.call(url, version: 33).status
55
+ end
56
+
57
+ it 'uses the cache' do
58
+ expect(fetcher).to receive(:get).once
59
+ 3.times { performer.call(url).status }
60
+ expect(performer.call(url).body.t).to eq(1)
61
+ end
62
+
63
+ context 'with a listener' do
64
+ before { subject.subscribe(listener) }
65
+
66
+ it 'emits :cache_miss' do
67
+ expect(listener).to receive(:cache_miss)
68
+ performer.call(url).status
69
+ end
70
+
71
+ it 'misses on different locale' do
72
+ expect(listener).to receive(:cache_miss).twice
73
+ performer.call(url, locale: 'en').status
74
+ performer.call(url, locale: 'fr').status
75
+ end
76
+
77
+ it 'emits :cache_miss' do
78
+ allow(listener).to receive(:cache_miss)
79
+ performer.call(url).status
80
+ expect(listener).to receive(:cache_hit)
81
+ performer.call(url).status
82
+ end
83
+ end
84
+ end
85
+
86
+
87
+ describe '#get' do
88
+ let(:performer) { subject.method(:get) }
89
+
90
+ it_behaves_like 'a response getter'
91
+ end
92
+
93
+
94
+ describe '#fget' do
95
+ let(:performer) { subject.method(:fget) }
96
+
97
+ it_behaves_like 'a response getter'
98
+ end
99
+
100
+
101
+ describe '#bust' do
102
+ it 'causes next get to query' do
103
+ expect(fetcher).to receive(:get).and_return(make_response(1)).exactly(:twice)
104
+ subject.get(url)
105
+ subject.bust(url)
106
+ subject.get(url)
107
+ end
108
+
109
+ it 'emits :cache_bust' do
110
+ subject.subscribe(listener, prefix: true)
111
+ expect(listener).to receive(:on_cache_bust).once
112
+ subject.bust(url)
113
+ end
114
+ end
115
+ end