routemaster-drain 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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