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.
- checksums.yaml +7 -0
- data/.env.test +6 -0
- data/.gitignore +7 -0
- data/.gitmodules +3 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/.travis.yml +16 -0
- data/.yardopts +6 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +122 -0
- data/Guardfile +10 -0
- data/LICENSE.txt +22 -0
- data/README.md +261 -0
- data/Rakefile +1 -0
- data/lib/routemaster/cache.rb +133 -0
- data/lib/routemaster/config.rb +57 -0
- data/lib/routemaster/dirty/filter.rb +49 -0
- data/lib/routemaster/dirty/map.rb +63 -0
- data/lib/routemaster/dirty/state.rb +33 -0
- data/lib/routemaster/drain.rb +5 -0
- data/lib/routemaster/drain/basic.rb +40 -0
- data/lib/routemaster/drain/caching.rb +43 -0
- data/lib/routemaster/drain/mapping.rb +43 -0
- data/lib/routemaster/drain/terminator.rb +31 -0
- data/lib/routemaster/fetcher.rb +63 -0
- data/lib/routemaster/jobs/cache.rb +12 -0
- data/lib/routemaster/jobs/cache_and_sweep.rb +16 -0
- data/lib/routemaster/middleware/authenticate.rb +59 -0
- data/lib/routemaster/middleware/cache.rb +29 -0
- data/lib/routemaster/middleware/dirty.rb +33 -0
- data/lib/routemaster/middleware/filter.rb +26 -0
- data/lib/routemaster/middleware/parse.rb +43 -0
- data/lib/routemaster/middleware/root_post_only.rb +18 -0
- data/lib/routemaster/redis_broker.rb +42 -0
- data/routemaster-drain.gemspec +28 -0
- data/spec/routemaster/cache_spec.rb +115 -0
- data/spec/routemaster/dirty/filter_spec.rb +77 -0
- data/spec/routemaster/dirty/map_spec.rb +122 -0
- data/spec/routemaster/dirty/state_spec.rb +41 -0
- data/spec/routemaster/drain/basic_spec.rb +37 -0
- data/spec/routemaster/drain/caching_spec.rb +47 -0
- data/spec/routemaster/drain/mapping_spec.rb +51 -0
- data/spec/routemaster/drain/terminator_spec.rb +61 -0
- data/spec/routemaster/fetcher_spec.rb +56 -0
- data/spec/routemaster/middleware/authenticate_spec.rb +59 -0
- data/spec/routemaster/middleware/cache_spec.rb +35 -0
- data/spec/routemaster/middleware/dirty_spec.rb +33 -0
- data/spec/routemaster/middleware/filter_spec.rb +35 -0
- data/spec/routemaster/middleware/parse_spec.rb +69 -0
- data/spec/routemaster/middleware/root_post_only_spec.rb +30 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/support/events.rb +9 -0
- data/spec/support/rack_test.rb +23 -0
- data/spec/support/uses_dotenv.rb +11 -0
- data/spec/support/uses_redis.rb +15 -0
- data/spec/support/uses_webmock.rb +12 -0
- data/test.rb +17 -0
- 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,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
|