routemaster-drain 1.0.5 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -37,6 +37,10 @@ module Routemaster
37
37
  end
38
38
  end
39
39
 
40
+ def queue_adapter
41
+ ENV.fetch('ROUTEMASTER_QUEUE_ADAPTER', 'resque').to_sym
42
+ end
43
+
40
44
  def queue_name
41
45
  ENV.fetch('ROUTEMASTER_QUEUE_NAME', 'routemaster')
42
46
  end
@@ -8,7 +8,7 @@ module Routemaster
8
8
  # refreshed.
9
9
  # Typically +mark+ is called when notified state has changed (e.g. from the
10
10
  # bus) and +sweep+ when one wants to know what has changed.
11
- #
11
+ #
12
12
  # Use case: when some entites are very volatile, the map will hold a "dirty"
13
13
  # state for multiple updates until the client is ready to update.
14
14
  #
@@ -1,5 +1,5 @@
1
1
  module Routemaster
2
2
  module Drain
3
- VERSION = '1.0.5'
3
+ VERSION = '1.1.0'
4
4
  end
5
5
  end
@@ -1,30 +1,23 @@
1
+ require 'base64'
1
2
  require 'faraday'
2
3
  require 'faraday_middleware'
3
4
  require 'hashie'
4
5
  require 'routemaster/config'
6
+ require 'routemaster/middleware/caching'
5
7
 
6
8
  module Routemaster
7
- # Fetches URLs from JSON APIs.
8
9
  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
10
+ DEFAULT_MIDDLEWARE = Routemaster::Middleware::Caching
25
11
 
26
- def initialize(host)
27
- @host = host
12
+ #
13
+ # Usage:
14
+ #
15
+ # You can extend Fetcher with custom middlewares like:
16
+ # Fetcher.new(middlewares: [[MyCustomMiddleWare, option1, option2]])
17
+ #
18
+ def initialize(middlewares: [], listener: nil)
19
+ @listener = listener
20
+ @middlewares = generate_middlewares(middlewares)
28
21
  end
29
22
 
30
23
  # Performs a GET HTTP request for the `url`, with optional
@@ -33,17 +26,21 @@ module Routemaster
33
26
  # @return an object that responds to `status` (integer), `headers` (hash),
34
27
  # and `body`. The body is a `Hashie::Mash` if the response was JSON, a
35
28
  # 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)
29
+ def get(url, params: {}, headers: {})
30
+ host = URI.parse(url).host
31
+ connection.get(url, params, headers.merge(auth_header(host)))
39
32
  end
40
33
 
41
34
  private
42
35
 
43
- def _connection
44
- @_connection ||= Faraday.new do |f|
36
+ def generate_middlewares(middlewares)
37
+ default_middleware = [DEFAULT_MIDDLEWARE, listener: @listener]
38
+ middlewares.unshift(default_middleware)
39
+ end
40
+
41
+ def connection
42
+ @connection ||= Faraday.new do |f|
45
43
  f.request :retry, max: 2, interval: 100e-3, backoff_factor: 2
46
- f.request :basic_auth, *_uuid
47
44
  f.response :mashify
48
45
  f.response :json, content_type: /\bjson/
49
46
  f.adapter :net_http_persistent
@@ -51,12 +48,16 @@ module Routemaster
51
48
  f.options.timeout = ENV.fetch('ROUTEMASTER_CACHE_TIMEOUT', 1).to_f
52
49
  f.options.open_timeout = ENV.fetch('ROUTEMASTER_CACHE_TIMEOUT', 1).to_f
53
50
  f.ssl.verify = ENV.fetch('ROUTEMASTER_CACHE_VERIFY_SSL', 'false') == 'true'
51
+
52
+ @middlewares.each do |middleware|
53
+ f.use(*middleware)
54
+ end
54
55
  end
55
56
  end
56
57
 
57
- def _uuid
58
- @_uuid ||= Config.cache_auth[@host]
58
+ def auth_header(host)
59
+ auth_string = Config.cache_auth.fetch(host, []).join(':')
60
+ { 'Authorization' => "Basic #{Base64.strict_encode64(auth_string)}" }
59
61
  end
60
62
  end
61
63
  end
62
-
@@ -0,0 +1,25 @@
1
+ require 'resque'
2
+ require 'routemaster/jobs/job'
3
+
4
+ module Routemaster
5
+ module Jobs
6
+ module Backends
7
+ class Resque
8
+ def initialize(adapter = nil)
9
+ @adapter = adapter || ::Resque
10
+ end
11
+
12
+ def enqueue(queue, job_class, *args)
13
+ job_data = Job.data_for(job_class, args)
14
+ @adapter.enqueue_to(queue, JobWrapper, job_data)
15
+ end
16
+
17
+ class JobWrapper
18
+ def self.perform(job_data)
19
+ Job.execute(job_data)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ require 'sidekiq'
2
+ require 'routemaster/jobs/job'
3
+
4
+ module Routemaster
5
+ module Jobs
6
+ module Backends
7
+ class Sidekiq
8
+ def initialize(adapter = nil)
9
+ @adapter = adapter || ::Sidekiq::Client
10
+ end
11
+
12
+ def enqueue(queue, job_class, *args)
13
+ job_data = Job.data_for(job_class, args)
14
+ @adapter.push('queue' => queue, 'class' => JobWrapper, 'args' => [job_data])
15
+ end
16
+
17
+ class JobWrapper
18
+ include ::Sidekiq::Worker
19
+
20
+ def perform(job_data)
21
+ Job.execute(job_data)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -4,8 +4,8 @@ module Routemaster
4
4
  module Jobs
5
5
  # Caches a URL using {Cache}.
6
6
  class Cache
7
- def self.perform(url)
8
- Cache.new.get(url)
7
+ def perform(url)
8
+ Routemaster::Cache.new.get(url)
9
9
  end
10
10
  end
11
11
  end
@@ -6,7 +6,7 @@ module Routemaster
6
6
  # Caches a URL using {Cache}, and sweeps the dirty map
7
7
  # if sucessful.
8
8
  class CacheAndSweep
9
- def self.perform(url)
9
+ def perform(url)
10
10
  Dirty::Map.new.sweep_one(url) do
11
11
  Cache.new.get(url)
12
12
  end
@@ -0,0 +1,30 @@
1
+ require 'routemaster/config'
2
+
3
+ module Routemaster
4
+ module Jobs
5
+ class Client
6
+ extend Forwardable
7
+
8
+ def_delegators :@backend, :enqueue
9
+
10
+ def initialize(adapter = nil)
11
+ @backend = build_backend(adapter)
12
+ end
13
+
14
+ private
15
+
16
+ def build_backend(adapter)
17
+ case Config.queue_adapter
18
+ when :resque
19
+ require 'routemaster/jobs/backends/resque'
20
+ Backends::Resque.new(adapter)
21
+ when :sidekiq
22
+ require 'routemaster/jobs/backends/sidekiq'
23
+ Backends::Sidekiq.new(adapter)
24
+ else
25
+ raise "Unsupported queue adapter '#{Config.queue_adapter}"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,23 @@
1
+ module Routemaster
2
+ module Jobs
3
+ class Job
4
+ class << self
5
+ def data_for(job_class, args)
6
+ { 'class' => job_class.to_s, 'args' => args }
7
+ end
8
+
9
+ def execute(job_data)
10
+ job = create_job(job_data)
11
+ job.new.perform(*job_data['args'])
12
+ end
13
+
14
+ private
15
+
16
+ def create_job(job_data)
17
+ job_class = job_data['class']
18
+ Kernel.const_get(job_class)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,22 +1,22 @@
1
1
  require 'routemaster/cache'
2
2
  require 'routemaster/config'
3
+ require 'routemaster/jobs/client'
3
4
  require 'routemaster/jobs/cache_and_sweep'
4
- require 'resque'
5
5
 
6
6
  module Routemaster
7
7
  module Middleware
8
8
  class Cache
9
- def initialize(app, cache:nil, resque:nil, queue:nil)
9
+ def initialize(app, cache:nil, client:nil, queue:nil)
10
10
  @app = app
11
11
  @cache = cache || Routemaster::Cache.new
12
- @resque = resque || Resque
12
+ @client = client || Routemaster::Jobs::Client.new
13
13
  @queue = queue || Config.queue_name
14
14
  end
15
15
 
16
16
  def call(env)
17
17
  env.fetch('routemaster.dirty', []).each do |url|
18
18
  @cache.bust(url)
19
- @resque.enqueue_to(@queue, Routemaster::Jobs::CacheAndSweep, url)
19
+ @client.enqueue(@queue, Routemaster::Jobs::CacheAndSweep, url)
20
20
  end
21
21
  @app.call(env)
22
22
  end
@@ -0,0 +1,71 @@
1
+ require 'wisper'
2
+
3
+ module Routemaster
4
+ module Middleware
5
+ class Caching
6
+ KEY_TEMPLATE = 'cache:{url}'
7
+ FIELD_TEMPLATE = 'v:{version},l:{locale}'
8
+ VERSION_REGEX = /application\/json;v=(?<version>\S*)/.freeze
9
+
10
+ def initialize(app, cache: Config.cache_redis, listener: nil)
11
+ @app = app
12
+ @cache = cache
13
+ @expiry = Config.cache_expiry
14
+ @listener = listener
15
+ end
16
+
17
+ def call(env)
18
+ return @app.call(env) unless env.method == :get
19
+
20
+ fetch_from_cache(env) || fetch_from_service(env)
21
+ end
22
+
23
+ private
24
+
25
+ def fetch_from_service(env)
26
+ @app.call(env).on_complete do |response_env|
27
+ response = response_env.response
28
+
29
+ if response.success?
30
+ @cache.hset(cache_key(env), cache_field(env), response.body)
31
+ @cache.expire(cache_key(env), @expiry)
32
+ @listener._publish(:cache_miss, url(env)) if @listener
33
+ end
34
+ end
35
+ end
36
+
37
+ def fetch_from_cache(env)
38
+ payload = @cache.hget(cache_key(env), cache_field(env))
39
+
40
+ if payload
41
+ @listener._publish(:cache_hit, url(env)) if @listener
42
+ Faraday::Response.new(status: 200,
43
+ body: payload,
44
+ response_headers: {})
45
+ end
46
+ end
47
+
48
+ def cache_field(env)
49
+ FIELD_TEMPLATE
50
+ .gsub('{version}', version(env).to_s)
51
+ .gsub('{locale}', locale(env).to_s)
52
+ end
53
+
54
+ def cache_key(env)
55
+ KEY_TEMPLATE.gsub('{url}', url(env))
56
+ end
57
+
58
+ def url(env)
59
+ env.url.to_s
60
+ end
61
+
62
+ def version(env)
63
+ (env.request_headers['Accept'] || "")[VERSION_REGEX, 1]
64
+ end
65
+
66
+ def locale(env)
67
+ env.request_headers['Accept-Language']
68
+ end
69
+ end
70
+ end
71
+ end
@@ -18,11 +18,10 @@ Gem::Specification.new do |spec|
18
18
 
19
19
  spec.add_runtime_dependency 'faraday', '>= 0.9.0'
20
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', '>= 1.4.0'
21
+ spec.add_runtime_dependency 'net-http-persistent', '< 3' # 3.x is currently incompatible with faraday
22
+ spec.add_runtime_dependency 'rack', '>= 1.6.2'
23
+ spec.add_runtime_dependency 'wisper', '~> 1.6.1'
24
24
  spec.add_runtime_dependency 'hashie'
25
25
  spec.add_runtime_dependency 'redis-namespace'
26
- spec.add_runtime_dependency 'resque'
27
26
  spec.add_runtime_dependency 'thread'
28
27
  end
@@ -1,115 +1,103 @@
1
1
  require 'spec_helper'
2
2
  require 'spec/support/uses_redis'
3
3
  require 'spec/support/uses_dotenv'
4
+ require 'spec/support/uses_webmock'
4
5
  require 'routemaster/cache'
5
6
  require 'routemaster/fetcher'
6
7
 
7
- describe Routemaster::Cache do
8
- uses_dotenv
9
- uses_redis
8
+ module Routemaster
9
+ describe Cache do
10
+ uses_dotenv
11
+ uses_redis
12
+ uses_webmock
10
13
 
11
- let(:fetcher) { double 'fetcher' }
12
- let(:url) { make_url(1) }
13
- let(:listener) { double 'listener' }
14
- subject { described_class.new(fetcher: fetcher) }
14
+ let(:url) { 'https://www.example.com/widgets/123' }
15
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
16
  before do
30
- @counter = 0
31
- allow(fetcher).to receive(:get) do |url, **options|
32
- make_response(@counter += 1)
33
- end
17
+ stub_request(:get, /example\.com/).to_return(
18
+ status: 200,
19
+ body: { id: 123, type: 'widget' }.to_json,
20
+ headers: {
21
+ 'content-type' => 'application/json;v=1'
22
+ }
23
+ )
34
24
  end
35
25
 
36
- it 'fetches the url' do
37
- expect(fetcher).to receive(:get).with(url, anything)
38
- performer.call(url).status
39
- end
26
+ shared_examples 'a GET request' do
27
+ context 'with no options' do
28
+ let(:options) { {} }
40
29
 
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
30
+ it 'calls get on the fetcher with no version and locale headers' do
31
+ expect_any_instance_of(Fetcher)
32
+ .to receive(:get)
33
+ .with(url, headers: { 'Accept' => 'application/json' })
34
+ .and_call_original
48
35
 
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)
36
+ perform.status
37
+ end
53
38
  end
54
- performer.call(url, version: 33).status
55
- end
56
39
 
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
40
+ context 'with a specific version' do
41
+ let(:options) { { version: 2 } }
62
42
 
63
- context 'with a listener' do
64
- before { subject.subscribe(listener) }
43
+ it 'calls get on the fetcher with version header' do
44
+ expect_any_instance_of(Fetcher)
45
+ .to receive(:get)
46
+ .with(url, headers: { 'Accept' => 'application/json;v=2' })
47
+ .and_call_original
65
48
 
66
- it 'emits :cache_miss' do
67
- expect(listener).to receive(:cache_miss)
68
- performer.call(url).status
49
+ perform.status
50
+ end
69
51
  end
70
52
 
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
53
+ context 'with a specific locale' do
54
+ let(:options) { { locale: 'fr' } }
76
55
 
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
56
+ it 'calls get on the fetcher with locale header' do
57
+ expect_any_instance_of(Fetcher)
58
+ .to receive(:get)
59
+ .with(url, headers: { 'Accept' => 'application/json', 'Accept-Language' => 'fr' })
60
+ .and_call_original
61
+
62
+ perform.status
63
+ end
82
64
  end
83
65
  end
84
- end
85
66
 
67
+ describe '#get' do
68
+ let(:perform) { subject.get(url, **options) }
86
69
 
87
- describe '#get' do
88
- let(:performer) { subject.method(:get) }
70
+ it_behaves_like 'a GET request'
71
+ end
89
72
 
90
- it_behaves_like 'a response getter'
91
- end
73
+ describe '#fget' do
74
+ let(:perform) { subject.fget(url, **options) }
92
75
 
76
+ it_behaves_like 'a GET request'
77
+ end
93
78
 
94
- describe '#fget' do
95
- let(:performer) { subject.method(:fget) }
79
+ describe '#bust' do
80
+ let(:cache) { Config.cache_redis }
81
+ let(:perform) { subject.bust(url) }
96
82
 
97
- it_behaves_like 'a response getter'
98
- end
83
+ before do
84
+ cache.set("cache:#{url}", "cached response")
85
+ end
99
86
 
87
+ it 'busts the cache for a given URL' do
88
+ expect { perform }
89
+ .to change { cache.get("cache:#{url}") }
90
+ .from('cached response')
91
+ .to(nil)
92
+ end
100
93
 
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
94
+ it 'publishes the cache_bust event for that URL' do
95
+ expect_any_instance_of(described_class)
96
+ .to receive(:publish)
97
+ .with(:cache_bust, url)
108
98
 
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)
99
+ perform
100
+ end
113
101
  end
114
102
  end
115
103
  end