routemaster-drain 1.0.5 → 1.1.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.
@@ -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