routemaster-drain 1.1.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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