routemaster-drain 1.1.0 → 2.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.
@@ -0,0 +1,73 @@
1
+ module Routemaster
2
+ module Errors
3
+ class BaseError < RuntimeError
4
+ attr_reader :env
5
+
6
+ def initialize(env)
7
+ @env = env
8
+ super(message)
9
+ end
10
+
11
+ def errors
12
+ body['errors']
13
+ end
14
+
15
+ def message
16
+ raise NotImplementedError
17
+ end
18
+
19
+ def body
20
+ @body ||= deserialized_body
21
+ end
22
+
23
+ private
24
+
25
+ def deserialized_body
26
+ @env.body.empty? ? {} : JSON.parse(@env.body)
27
+ end
28
+ end
29
+
30
+ class UnauthorizedResourceAccessError < BaseError
31
+ def message
32
+ "Unauthorized Resource Access Error"
33
+ end
34
+ end
35
+
36
+ class InvalidResourceError < BaseError
37
+ def message
38
+ "Invalid Resource Error"
39
+ end
40
+ end
41
+
42
+ class ResourceNotFoundError < BaseError
43
+ def message
44
+ "Resource Not Found Error"
45
+ end
46
+ end
47
+
48
+ class FatalResourceError < BaseError
49
+ def message
50
+ "Fatal Resource Error. body: #{body}, url: #{env.url}, method: #{env.method}"
51
+ end
52
+ end
53
+
54
+ class ConflictResourceError < BaseError
55
+ def message
56
+ "ConflictResourceError Resource Error"
57
+ end
58
+ end
59
+
60
+ class IncompatibleVersionError < BaseError
61
+ def message
62
+ headers = env.request_headers.select { |k, _| k != 'Authorization' }
63
+ "Incompatible Version Error. headers: #{headers}, url: #{env.url}, method: #{env.method}"
64
+ end
65
+ end
66
+
67
+ class ResourceThrottlingError < BaseError
68
+ def message
69
+ "Resource Throttling Error"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,28 @@
1
+ require 'faraday_middleware'
2
+ require 'routemaster/errors'
3
+
4
+ module Routemaster
5
+ module Middleware
6
+ class ErrorHandling < Faraday::Response::Middleware
7
+ ERRORS_MAPPING = {
8
+ (400..400) => Errors::InvalidResourceError,
9
+ (401..401) => Errors::UnauthorizedResourceAccessError,
10
+ (403..403) => Errors::UnauthorizedResourceAccessError,
11
+ (404..404) => Errors::ResourceNotFoundError,
12
+ (409..409) => Errors::ConflictResourceError,
13
+ (412..412) => Errors::IncompatibleVersionError,
14
+ (413..413) => Errors::InvalidResourceError,
15
+ (429..429) => Errors::ResourceThrottlingError,
16
+ (407..500) => Errors::FatalResourceError
17
+ }.freeze
18
+
19
+ def on_complete(env)
20
+ ERRORS_MAPPING.each do |range, error_class|
21
+ if range.include?(env[:status])
22
+ raise error_class.new(env)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -2,9 +2,10 @@ require 'wisper'
2
2
 
3
3
  module Routemaster
4
4
  module Middleware
5
- class Caching
5
+ class ResponseCaching
6
6
  KEY_TEMPLATE = 'cache:{url}'
7
- FIELD_TEMPLATE = 'v:{version},l:{locale}'
7
+ BODY_FIELD_TEMPLATE = 'v:{version},l:{locale},body'
8
+ HEADERS_FIELD_TEMPLATE = 'v:{version},l:{locale},headers'
8
9
  VERSION_REGEX = /application\/json;v=(?<version>\S*)/.freeze
9
10
 
10
11
  def initialize(app, cache: Config.cache_redis, listener: nil)
@@ -27,27 +28,38 @@ module Routemaster
27
28
  response = response_env.response
28
29
 
29
30
  if response.success?
30
- @cache.hset(cache_key(env), cache_field(env), response.body)
31
- @cache.expire(cache_key(env), @expiry)
31
+ @cache.multi do |multi|
32
+ multi.hset(cache_key(env), body_cache_field(env), response.body)
33
+ multi.hset(cache_key(env), headers_cache_field(env), Marshal.dump(response.headers))
34
+ multi.expire(cache_key(env), @expiry)
35
+ end
32
36
  @listener._publish(:cache_miss, url(env)) if @listener
33
37
  end
34
38
  end
35
39
  end
36
40
 
37
41
  def fetch_from_cache(env)
38
- payload = @cache.hget(cache_key(env), cache_field(env))
42
+ body = @cache.hget(cache_key(env), body_cache_field(env))
43
+ headers = @cache.hget(cache_key(env), headers_cache_field(env))
39
44
 
40
- if payload
45
+ if body && headers
41
46
  @listener._publish(:cache_hit, url(env)) if @listener
42
47
  Faraday::Response.new(status: 200,
43
- body: payload,
44
- response_headers: {})
48
+ body: body,
49
+ response_headers: Marshal.load(headers),
50
+ request: {})
45
51
  end
46
52
  end
47
53
 
48
- def cache_field(env)
49
- FIELD_TEMPLATE
50
- .gsub('{version}', version(env).to_s)
54
+ def body_cache_field(env)
55
+ BODY_FIELD_TEMPLATE
56
+ .gsub('{version}', version(env).to_s)
57
+ .gsub('{locale}', locale(env).to_s)
58
+ end
59
+
60
+ def headers_cache_field(env)
61
+ HEADERS_FIELD_TEMPLATE
62
+ .gsub('{version}', version(env).to_s)
51
63
  .gsub('{locale}', locale(env).to_s)
52
64
  end
53
65
 
@@ -0,0 +1,26 @@
1
+ require 'routemaster/api_client'
2
+
3
+ module Routemaster
4
+ module Resources
5
+ class RestResource
6
+ attr_reader :url
7
+
8
+ def initialize(url, client: nil)
9
+ @url = url
10
+ @client = client || Routemaster::APIClient.new(response_class: Responses::HateoasResponse)
11
+ end
12
+
13
+ def create(params)
14
+ @client.post(@url, body: params)
15
+ end
16
+
17
+ def show(id=nil)
18
+ @client.get(@url.gsub('{id}', id.to_s))
19
+ end
20
+
21
+ def index
22
+ @client.get(@url)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,51 @@
1
+ require 'faraday_middleware'
2
+ require 'routemaster/api_client'
3
+ require 'routemaster/responses/hateoas_response'
4
+ require 'routemaster/resources/rest_resource'
5
+ require 'forwardable'
6
+ require 'json'
7
+
8
+ module Routemaster
9
+ module Responses
10
+ class HateoasResponse
11
+ extend Forwardable
12
+
13
+ attr_reader :response
14
+ def_delegators :@response, :body, :status, :headers, :success?
15
+
16
+ def initialize(response, client: nil)
17
+ @response = response
18
+ @client = client || Routemaster::APIClient.new(response_class: Routemaster::Responses::HateoasResponse)
19
+ end
20
+
21
+ def method_missing(m, *args, &block)
22
+ method_name = m.to_s
23
+ normalized_method_name = method_name == '_self' ? 'self' : method_name
24
+
25
+ if _links.keys.include?(normalized_method_name)
26
+ unless respond_to?(method_name)
27
+ resource = Resources::RestResource.new(_links[normalized_method_name]['href'], client: @client)
28
+
29
+ self.class.send(:define_method, method_name) do |*m_args|
30
+ resource
31
+ end
32
+
33
+ resource
34
+ end
35
+ else
36
+ super
37
+ end
38
+ end
39
+
40
+ def body_without_links
41
+ body.reject { |key, _| ['_links'].include?(key) }
42
+ end
43
+
44
+ private
45
+
46
+ def _links
47
+ @links ||= @response.body.fetch('_links', {})
48
+ end
49
+ end
50
+ end
51
+ end
@@ -19,7 +19,7 @@ Gem::Specification.new do |spec|
19
19
  spec.add_runtime_dependency 'faraday', '>= 0.9.0'
20
20
  spec.add_runtime_dependency 'faraday_middleware'
21
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'
22
+ spec.add_runtime_dependency 'rack', '>= 1.4.5'
23
23
  spec.add_runtime_dependency 'wisper', '~> 1.6.1'
24
24
  spec.add_runtime_dependency 'hashie'
25
25
  spec.add_runtime_dependency 'redis-namespace'
@@ -2,10 +2,10 @@ require 'spec_helper'
2
2
  require 'spec/support/uses_dotenv'
3
3
  require 'spec/support/uses_redis'
4
4
  require 'spec/support/uses_webmock'
5
- require 'routemaster/fetcher'
5
+ require 'routemaster/api_client'
6
6
  require 'json'
7
7
 
8
- describe Routemaster::Fetcher do
8
+ describe Routemaster::APIClient do
9
9
  uses_dotenv
10
10
  uses_redis
11
11
  uses_webmock
@@ -24,6 +24,14 @@ describe Routemaster::Fetcher do
24
24
  'content-type' => 'application/json;v=1'
25
25
  }
26
26
  )
27
+
28
+ @post_req = stub_request(:post, /example\.com/).to_return(
29
+ status: 200,
30
+ body: { id: 132, type: 'widget' }.to_json,
31
+ headers: {
32
+ 'content-type' => 'application/json;v=1'
33
+ }
34
+ )
27
35
  end
28
36
 
29
37
  it 'GETs from the URL' do
@@ -31,6 +39,15 @@ describe Routemaster::Fetcher do
31
39
  expect(@req).to have_been_requested
32
40
  end
33
41
 
42
+ context 'POST request' do
43
+ subject { fetcher.post(url, body: {}, headers: headers) }
44
+
45
+ it 'POSTs from the URL' do
46
+ subject
47
+ expect(@post_req).to have_been_requested
48
+ end
49
+ end
50
+
34
51
  it 'has :status, :headers, :body' do
35
52
  expect(subject.status).to eq(200)
36
53
  expect(subject.headers).to have_key('content-type')
@@ -56,5 +73,19 @@ describe Routemaster::Fetcher do
56
73
  expect(req.headers).to include('X-Custom-Header')
57
74
  end
58
75
  end
76
+
77
+ context 'when response_class is present' do
78
+ before do
79
+ class DummyResponse
80
+ def initialize(res, client: nil); end
81
+ end
82
+ end
83
+
84
+ let(:fetcher) { described_class.new(response_class: DummyResponse) }
85
+
86
+ it 'returns a response_class instance as a response' do
87
+ expect(subject).to be_an_instance_of(DummyResponse)
88
+ end
89
+ end
59
90
  end
60
91
  end
@@ -3,7 +3,7 @@ require 'spec/support/uses_redis'
3
3
  require 'spec/support/uses_dotenv'
4
4
  require 'spec/support/uses_webmock'
5
5
  require 'routemaster/cache'
6
- require 'routemaster/fetcher'
6
+ require 'routemaster/api_client'
7
7
 
8
8
  module Routemaster
9
9
  describe Cache do
@@ -27,8 +27,8 @@ module Routemaster
27
27
  context 'with no options' do
28
28
  let(:options) { {} }
29
29
 
30
- it 'calls get on the fetcher with no version and locale headers' do
31
- expect_any_instance_of(Fetcher)
30
+ it 'calls get on the api client with no version and locale headers' do
31
+ expect_any_instance_of(APIClient)
32
32
  .to receive(:get)
33
33
  .with(url, headers: { 'Accept' => 'application/json' })
34
34
  .and_call_original
@@ -40,8 +40,8 @@ module Routemaster
40
40
  context 'with a specific version' do
41
41
  let(:options) { { version: 2 } }
42
42
 
43
- it 'calls get on the fetcher with version header' do
44
- expect_any_instance_of(Fetcher)
43
+ it 'calls get on the api client with version header' do
44
+ expect_any_instance_of(APIClient)
45
45
  .to receive(:get)
46
46
  .with(url, headers: { 'Accept' => 'application/json;v=2' })
47
47
  .and_call_original
@@ -53,8 +53,8 @@ module Routemaster
53
53
  context 'with a specific locale' do
54
54
  let(:options) { { locale: 'fr' } }
55
55
 
56
- it 'calls get on the fetcher with locale header' do
57
- expect_any_instance_of(Fetcher)
56
+ it 'calls get on the api client with locale header' do
57
+ expect_any_instance_of(APIClient)
58
58
  .to receive(:get)
59
59
  .with(url, headers: { 'Accept' => 'application/json', 'Accept-Language' => 'fr' })
60
60
  .and_call_original
@@ -0,0 +1,76 @@
1
+ require 'spec_helper'
2
+ require 'spec/support/uses_redis'
3
+ require 'spec/support/uses_dotenv'
4
+ require 'routemaster/api_client'
5
+ require 'webrick'
6
+
7
+ RSpec.describe 'Api client integration specs' do
8
+ uses_dotenv
9
+ uses_redis
10
+
11
+ let!(:log) { WEBrick::Log.new '/dev/null' }
12
+ let(:service) do
13
+ WEBrick::HTTPServer.new(Port: 8000, DocumentRoot: Dir.pwd, Logger: log).tap do |server|
14
+ [400, 401, 403, 404, 409, 412, 413, 429, 500].each do |status_code|
15
+ server.mount_proc "/#{status_code}" do |req, res|
16
+ res.status = status_code
17
+ res.body = { field: 'test' }.to_json
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+ before do
24
+ @pid = fork do
25
+ trap 'INT' do service.shutdown end
26
+ service.start
27
+ end
28
+ sleep(0.5) # leave sometime for the previous webrick to teardown
29
+ end
30
+
31
+ after do
32
+ Process.kill('KILL', @pid)
33
+ Process.wait(@pid)
34
+ end
35
+
36
+ subject { Routemaster::APIClient.new }
37
+ let(:host) { 'http://localhost:8000' }
38
+
39
+ describe 'error handling' do
40
+ it 'raises an ResourceNotFoundError on 404' do
41
+ expect { subject.get(host + '/404') }.to raise_error(Routemaster::Errors::ResourceNotFoundError)
42
+ end
43
+
44
+ it 'raises an InvalidResourceError on 400' do
45
+ expect { subject.get(host + '/400') }.to raise_error(Routemaster::Errors::InvalidResourceError)
46
+ end
47
+
48
+ it 'raises an UnauthorizedResourceAccessError on 401' do
49
+ expect { subject.get(host + '/401') }.to raise_error(Routemaster::Errors::UnauthorizedResourceAccessError)
50
+ end
51
+
52
+ it 'raises an UnauthorizedResourceAccessError on 403' do
53
+ expect { subject.get(host + '/403') }.to raise_error(Routemaster::Errors::UnauthorizedResourceAccessError)
54
+ end
55
+
56
+ it 'raises an ConflictResourceError on 409' do
57
+ expect { subject.get(host + '/409') }.to raise_error(Routemaster::Errors::ConflictResourceError)
58
+ end
59
+
60
+ it 'raises an IncompatibleVersionError on 412' do
61
+ expect { subject.get(host + '/412') }.to raise_error(Routemaster::Errors::IncompatibleVersionError)
62
+ end
63
+
64
+ it 'raises an InvalidResourceError on 413' do
65
+ expect { subject.get(host + '/413') }.to raise_error(Routemaster::Errors::InvalidResourceError)
66
+ end
67
+
68
+ it 'raises an ResourceThrottlingError on 429' do
69
+ expect { subject.get(host + '/429') }.to raise_error(Routemaster::Errors::ResourceThrottlingError)
70
+ end
71
+
72
+ it 'raises an FatalResourceError on 500' do
73
+ expect { subject.get(host + '/500') }.to raise_error(Routemaster::Errors::FatalResourceError)
74
+ end
75
+ end
76
+ end
@@ -33,7 +33,8 @@ RSpec.describe 'Requests with caching' do
33
33
  subject { Routemaster::Cache.new }
34
34
 
35
35
  describe 'GET request' do
36
- let(:cache_keys) { ["cache:#{url}", "v:,l:"] }
36
+ let(:body_cache_keys) { ["cache:#{url}", "v:,l:,body"] }
37
+ let(:headers_cache_keys) { ["cache:#{url}", "v:,l:,headers"] }
37
38
  let(:url) { 'http://localhost:8000/test' }
38
39
 
39
40
  context 'when there is no previous cached response' do
@@ -44,10 +45,16 @@ RSpec.describe 'Requests with caching' do
44
45
 
45
46
  it 'sets the new response onto the cache' do
46
47
  expect { subject.get(url) }
47
- .to change { Routemaster::Config.cache_redis.hget(*cache_keys)}
48
+ .to change { Routemaster::Config.cache_redis.hget(*body_cache_keys)}
48
49
  .from(nil)
49
50
  .to({ field: 'test'}.to_json)
50
51
  end
52
+
53
+ it 'sets the response headers onto the cache' do
54
+ expect { subject.get(url) }
55
+ .to change { Routemaster::Config.cache_redis.hget(*headers_cache_keys)}
56
+ .from(nil)
57
+ end
51
58
  end
52
59
 
53
60
  context 'when there is a previous cached response' do
@@ -61,7 +68,7 @@ RSpec.describe 'Requests with caching' do
61
68
 
62
69
  it 'does not make an http call' do
63
70
  response = subject.get(url)
64
- expect(response.headers['server']).to be_nil
71
+ expect(response.env.request).to be_empty
65
72
  end
66
73
  end
67
74
  end
@@ -8,11 +8,11 @@ describe Routemaster::Middleware::Authenticate do
8
8
  let(:app) { described_class.new(ErrorRackApp.new, options) }
9
9
  let(:listener) { double 'listener', on_authenticate: nil }
10
10
  let(:options) {{ uuid: 'demo' }}
11
-
11
+
12
12
  def perform
13
13
  post '/whatever'
14
14
  end
15
-
15
+
16
16
  before { Wisper.subscribe(listener, scope: described_class.name, prefix: true) }
17
17
  after { Wisper::GlobalListeners.clear }
18
18
 
@@ -0,0 +1,35 @@
1
+ require 'spec_helper'
2
+ require 'routemaster/resources/rest_resource'
3
+
4
+ module Routemaster
5
+ module Resources
6
+ RSpec.describe RestResource do
7
+ let(:url) { 'test_url' }
8
+ let(:client) { double('Client') }
9
+ let(:params) { {} }
10
+
11
+ subject { described_class.new(url, client: client) }
12
+
13
+ describe '#create' do
14
+ it 'posts to the given url' do
15
+ expect(client).to receive(:post).with(url, body: params)
16
+ subject.create(params)
17
+ end
18
+ end
19
+
20
+ describe '#show' do
21
+ it 'gets to the given url' do
22
+ expect(client).to receive(:get).with(url)
23
+ subject.show(1)
24
+ end
25
+ end
26
+
27
+ describe '#index' do
28
+ it 'gets to the given url' do
29
+ expect(client).to receive(:get).with(url)
30
+ subject.index
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,50 @@
1
+ require 'spec_helper'
2
+ require 'routemaster/responses/hateoas_response'
3
+
4
+ module Routemaster
5
+ module Responses
6
+ RSpec.describe HateoasResponse do
7
+ let(:response) { double('Response', status: status, body: body, headers: headers) }
8
+ let(:status) { 200 }
9
+ let(:body) { {}.to_json }
10
+ let(:headers) { {} }
11
+
12
+ subject { described_class.new(response) }
13
+
14
+ context 'link traversal' do
15
+ let(:body) do
16
+ {
17
+ '_links' => {
18
+ 'self' => { 'href' => 'self_url' },
19
+ 'resource_a' => { 'href' => 'resource_a_url' },
20
+ 'resource_b' => { 'href' => 'resource_b_url' }
21
+ }
22
+ }
23
+ end
24
+
25
+ it 'creates a method for every key in _links attribute' do
26
+ expect(subject.resource_a.url).to eq('resource_a_url')
27
+ expect(subject.resource_b.url).to eq('resource_b_url')
28
+ end
29
+
30
+ it 'creates a _self method if there is a link with name self' do
31
+ expect(subject._self.url).to eq('self_url')
32
+ end
33
+
34
+ it 'raise an exception when requested link does not exist' do
35
+ expect { subject.some_unsupported_link }.to raise_error(NoMethodError)
36
+ end
37
+
38
+ describe '#body_without_links' do
39
+ before do
40
+ body.merge!('foo' => 'bar')
41
+ end
42
+
43
+ it 'returns the body without the _links key' do
44
+ expect(subject.body_without_links).to eq({ 'foo' => 'bar' })
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end