coach 0.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +1 -0
- data/.rubocop.yml +123 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +104 -0
- data/README.md +260 -0
- data/coach.gemspec +28 -0
- data/lib/coach.rb +16 -0
- data/lib/coach/errors.rb +16 -0
- data/lib/coach/handler.rb +82 -0
- data/lib/coach/middleware.rb +96 -0
- data/lib/coach/middleware_item.rb +31 -0
- data/lib/coach/middleware_validator.rb +39 -0
- data/lib/coach/notifications.rb +88 -0
- data/lib/coach/request_benchmark.rb +59 -0
- data/lib/coach/request_serializer.rb +63 -0
- data/lib/coach/router.rb +70 -0
- data/lib/coach/version.rb +3 -0
- data/lib/spec/coach_helper.rb +113 -0
- data/spec/lib/coach/handler_spec.rb +138 -0
- data/spec/lib/coach/middleware_spec.rb +43 -0
- data/spec/lib/coach/middleware_validator_spec.rb +72 -0
- data/spec/lib/coach/notifications_spec.rb +73 -0
- data/spec/lib/coach/request_benchmark_spec.rb +42 -0
- data/spec/lib/coach/request_serializer_spec.rb +38 -0
- data/spec/lib/coach/router_spec.rb +78 -0
- data/spec/spec_helper.rb +14 -0
- metadata +162 -0
@@ -0,0 +1,63 @@
|
|
1
|
+
module Coach
|
2
|
+
class RequestSerializer
|
3
|
+
def self.header_rules
|
4
|
+
@header_rules ||= {}
|
5
|
+
end
|
6
|
+
|
7
|
+
# Sets global rules on how to sanitize headers. An optional block can be supplied
|
8
|
+
# that will determine how to transform the original header value, otherwise a default
|
9
|
+
# string is used.
|
10
|
+
def self.sanitize_header(header, &rule)
|
11
|
+
header_rules[header] = rule || ->(value) { '[FILTERED]' }
|
12
|
+
end
|
13
|
+
|
14
|
+
# Applies sanitizing rules. Expects `header` to be in 'http_header_name' form.
|
15
|
+
def self.apply_header_rule(header, value)
|
16
|
+
return value if header_rules[header].nil?
|
17
|
+
header_rules[header].call(value)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Resets all header sanitizing
|
21
|
+
def self.clear_header_rules!
|
22
|
+
@header_rules = {}
|
23
|
+
end
|
24
|
+
|
25
|
+
def initialize(request)
|
26
|
+
@request = request
|
27
|
+
end
|
28
|
+
|
29
|
+
def serialize
|
30
|
+
{
|
31
|
+
# Identification
|
32
|
+
request_id: @request.uuid,
|
33
|
+
|
34
|
+
# Request details
|
35
|
+
method: @request.method,
|
36
|
+
path: request_path,
|
37
|
+
format: @request.format.try(:ref),
|
38
|
+
params: @request.filtered_parameters, # uses config.filter_parameters
|
39
|
+
|
40
|
+
# Extra request info
|
41
|
+
headers: filtered_headers,
|
42
|
+
session_id: @request.remote_ip
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def request_path
|
49
|
+
@request.fullpath
|
50
|
+
rescue
|
51
|
+
"unknown"
|
52
|
+
end
|
53
|
+
|
54
|
+
def filtered_headers
|
55
|
+
header_value_pairs = @request.filtered_env.map do |key, value|
|
56
|
+
next unless key =~ /^HTTP_/
|
57
|
+
[key.downcase, self.class.apply_header_rule(key.downcase, value)]
|
58
|
+
end.compact
|
59
|
+
|
60
|
+
Hash[header_value_pairs]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
data/lib/coach/router.rb
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
require_relative "handler"
|
2
|
+
require_relative "errors"
|
3
|
+
|
4
|
+
module Coach
|
5
|
+
class Router
|
6
|
+
ACTION_TRAITS = {
|
7
|
+
index: { method: :get },
|
8
|
+
show: { method: :get, url: ':id' },
|
9
|
+
create: { method: :post },
|
10
|
+
update: { method: :put, url: ':id' },
|
11
|
+
destroy: { method: :delete, url: ':id' }
|
12
|
+
}.each_value(&:freeze).freeze
|
13
|
+
|
14
|
+
def initialize(app)
|
15
|
+
@app = app
|
16
|
+
end
|
17
|
+
|
18
|
+
def draw(routes, base: nil, as: nil, constraints: nil, actions: [])
|
19
|
+
action_traits(actions).each do |action, traits|
|
20
|
+
route = routes.const_get(camel(action))
|
21
|
+
match(action_url(base, traits),
|
22
|
+
to: Handler.new(route),
|
23
|
+
via: traits[:method],
|
24
|
+
as: as,
|
25
|
+
constraints: constraints)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def match(url, **args)
|
30
|
+
@app.routes.draw do
|
31
|
+
match url, args
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
# Receives an array of symbols that represent actions for which the default traits
|
38
|
+
# should be used, and then lastly an optional trait configuration.
|
39
|
+
#
|
40
|
+
# Example is...
|
41
|
+
#
|
42
|
+
# [ :index, :show, { refund: { url: ':id/refund', method: 'post' } } ]
|
43
|
+
#
|
44
|
+
# ...which will load the default route for `show` and `index`, while also configuring
|
45
|
+
# a refund route.
|
46
|
+
def action_traits(list_of_actions)
|
47
|
+
if list_of_actions.last.is_a?(Hash)
|
48
|
+
*list_of_actions, traits = list_of_actions
|
49
|
+
end
|
50
|
+
|
51
|
+
list_of_actions.reduce(traits || {}) do |memo, action|
|
52
|
+
trait = ACTION_TRAITS.fetch(action) do
|
53
|
+
raise Errors::RouterUnknownDefaultAction, action
|
54
|
+
end
|
55
|
+
|
56
|
+
memo.merge(action => trait)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Applies trait url to base, removing duplicate /'s
|
61
|
+
def action_url(base, traits)
|
62
|
+
[base, traits[:url]].compact.join('/').gsub(%r{/+}, '/')
|
63
|
+
end
|
64
|
+
|
65
|
+
# Turns a snake_case string/symbol into a CamelCase
|
66
|
+
def camel(snake_case)
|
67
|
+
snake_case.to_s.capitalize.gsub(/_./) { |match| match[1].upcase }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
require "coach/middleware"
|
3
|
+
|
4
|
+
# Middleware stubbing ######################################
|
5
|
+
|
6
|
+
def build_middleware(name)
|
7
|
+
Class.new(Coach::Middleware) do
|
8
|
+
# To access `name`, we need to use `define_method` instead of `def`
|
9
|
+
define_method(:to_s) { "<Middleware#{name}>" }
|
10
|
+
define_method(:name) { name }
|
11
|
+
define_singleton_method(:name) { name }
|
12
|
+
|
13
|
+
def call
|
14
|
+
config[:callback].call if config.include?(:callback)
|
15
|
+
|
16
|
+
# Build up a list of middleware called, in the order they were called
|
17
|
+
if next_middleware
|
18
|
+
[name].concat(next_middleware.call)
|
19
|
+
else
|
20
|
+
[name]
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def null_middleware
|
27
|
+
double(call: nil)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Response matchers ########################################
|
31
|
+
|
32
|
+
RSpec::Matchers.define :respond_with_status do |expected_status|
|
33
|
+
match do |middleware|
|
34
|
+
@middleware = middleware
|
35
|
+
@response = middleware.call
|
36
|
+
@response[0] == expected_status
|
37
|
+
end
|
38
|
+
|
39
|
+
failure_message do |actual|
|
40
|
+
"expected #{@middleware.class.name} to respond with #{expected_status} but got " \
|
41
|
+
"#{@response[0]}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
RSpec::Matchers.define :respond_with_body_that_matches do |body_regex|
|
46
|
+
match do |middleware|
|
47
|
+
@response_body = middleware.call.third.join
|
48
|
+
@response_body.match(body_regex)
|
49
|
+
end
|
50
|
+
|
51
|
+
failure_message do |actual|
|
52
|
+
"expected that \"#{@response_body}\" would match #{body_regex}"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
RSpec::Matchers.define :respond_with_envelope do |envelope, keys = []|
|
57
|
+
match do |middleware|
|
58
|
+
@response = JSON.parse(middleware.call.third.join)
|
59
|
+
expect(@response).to include(envelope.to_s)
|
60
|
+
|
61
|
+
@envelope = @response[envelope.to_s].with_indifferent_access
|
62
|
+
expect(@envelope).to match(hash_including(*keys))
|
63
|
+
end
|
64
|
+
|
65
|
+
failure_message do |actual|
|
66
|
+
"expected that \"#{@response}\" would have envelope \"#{envelope}\" that matches " \
|
67
|
+
"hash_including(#{keys})"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
RSpec::Matchers.define :respond_with_header do |header, value_regex|
|
72
|
+
match do |middleware|
|
73
|
+
response_headers = middleware.call.second
|
74
|
+
@header_value = response_headers[header]
|
75
|
+
@header_value.match(value_regex)
|
76
|
+
end
|
77
|
+
|
78
|
+
failure_message do |actual|
|
79
|
+
"expected #{header} header in response to match #{value_regex} but found " \
|
80
|
+
"\"#{@header_value}\""
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# Chain matchers ###########################################
|
85
|
+
|
86
|
+
RSpec::Matchers.define :call_next_middleware do
|
87
|
+
match do |middleware|
|
88
|
+
@middleware = middleware
|
89
|
+
allow(middleware.next_middleware).to receive(:call)
|
90
|
+
middleware.call
|
91
|
+
begin
|
92
|
+
expect(middleware.next_middleware).to have_received(:call)
|
93
|
+
true
|
94
|
+
rescue RSpec::Expectations::ExpectationNotMetError
|
95
|
+
false
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
failure_message do
|
100
|
+
"expected that \"#{@middleware.class.name}\" would call next middleware"
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Provide/Require matchers #################################
|
105
|
+
|
106
|
+
RSpec::Matchers.define :provide do |key|
|
107
|
+
match do |middleware|
|
108
|
+
allow(middleware).to receive(:provide)
|
109
|
+
expect(middleware).to receive(:provide).with(hash_including(key)).and_call_original
|
110
|
+
middleware.call
|
111
|
+
true
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
require "coach/handler"
|
4
|
+
require "coach/middleware"
|
5
|
+
require "coach/errors"
|
6
|
+
|
7
|
+
describe Coach::Handler do
|
8
|
+
let(:middleware_a) { build_middleware("A") }
|
9
|
+
let(:middleware_b) { build_middleware("B") }
|
10
|
+
let(:middleware_c) { build_middleware("C") }
|
11
|
+
|
12
|
+
let(:terminal_middleware) { build_middleware("Terminal") }
|
13
|
+
let(:handler) { Coach::Handler.new(terminal_middleware) }
|
14
|
+
|
15
|
+
before { Coach::Notifications.unsubscribe! }
|
16
|
+
|
17
|
+
describe "#call" do
|
18
|
+
let(:a_spy) { spy('middleware a') }
|
19
|
+
let(:b_spy) { spy('middleware b') }
|
20
|
+
|
21
|
+
before { terminal_middleware.uses(middleware_a, callback: a_spy) }
|
22
|
+
before { terminal_middleware.uses(middleware_b, callback: b_spy) }
|
23
|
+
|
24
|
+
it "invokes all middleware in the chain" do
|
25
|
+
result = handler.call({})
|
26
|
+
expect(a_spy).to have_received(:call)
|
27
|
+
expect(b_spy).to have_received(:call)
|
28
|
+
expect(result).to eq(%w(A B Terminal))
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
describe "#build_sequence" do
|
33
|
+
subject(:sequence) do
|
34
|
+
root_item = Coach::MiddlewareItem.new(terminal_middleware)
|
35
|
+
handler.build_sequence(root_item, {}).map(&:middleware)
|
36
|
+
end
|
37
|
+
|
38
|
+
context "given a route that includes simple middleware" do
|
39
|
+
before { terminal_middleware.uses(middleware_a) }
|
40
|
+
|
41
|
+
it "assembles a sequence including all middleware" do
|
42
|
+
expect(sequence).to match_array([middleware_a, terminal_middleware])
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
context "given a route that includes nested middleware" do
|
47
|
+
before { middleware_b.uses(middleware_c) }
|
48
|
+
before { middleware_a.uses(middleware_b) }
|
49
|
+
before { terminal_middleware.uses(middleware_a) }
|
50
|
+
|
51
|
+
it "assembles a sequence including all middleware" do
|
52
|
+
expect(sequence).to match_array([middleware_c, middleware_b,
|
53
|
+
middleware_a, terminal_middleware])
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
context "when a middleware has been included more than once" do
|
58
|
+
before { middleware_a.uses(middleware_c) }
|
59
|
+
before { middleware_b.uses(middleware_c) }
|
60
|
+
before { terminal_middleware.uses(middleware_a) }
|
61
|
+
before { terminal_middleware.uses(middleware_b) }
|
62
|
+
|
63
|
+
it "only appears once" do
|
64
|
+
expect(sequence).to match_array([middleware_c, middleware_a,
|
65
|
+
middleware_b, terminal_middleware])
|
66
|
+
end
|
67
|
+
|
68
|
+
context "with a different config" do
|
69
|
+
before { middleware_b.uses(middleware_c, foo: "bar") }
|
70
|
+
|
71
|
+
it "appears more than once" do
|
72
|
+
expect(sequence).to match_array([middleware_c, middleware_a,
|
73
|
+
middleware_c, middleware_b,
|
74
|
+
terminal_middleware])
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
describe "#build_request_chain" do
|
81
|
+
before { terminal_middleware.uses(middleware_a) }
|
82
|
+
before { terminal_middleware.uses(middleware_b, if: ->(ctx) { false }) }
|
83
|
+
before { terminal_middleware.uses(middleware_c) }
|
84
|
+
|
85
|
+
let(:root_item) { Coach::MiddlewareItem.new(terminal_middleware) }
|
86
|
+
let(:sequence) { handler.build_sequence(root_item, {}) }
|
87
|
+
|
88
|
+
it "instantiates all matching middleware items in the sequence" do
|
89
|
+
expect(middleware_a).to receive(:new)
|
90
|
+
expect(middleware_c).to receive(:new)
|
91
|
+
expect(terminal_middleware).to receive(:new)
|
92
|
+
handler.build_request_chain(sequence, {})
|
93
|
+
end
|
94
|
+
|
95
|
+
it "doesn't instantiate non-matching middleware items" do
|
96
|
+
expect(middleware_b).not_to receive(:new)
|
97
|
+
handler.build_request_chain(sequence, {})
|
98
|
+
end
|
99
|
+
|
100
|
+
it "sets up the chain correctly, calling each item in the correct order" do
|
101
|
+
expect(handler.build_request_chain(sequence, {}).call).
|
102
|
+
to eq(%w(A C Terminal))
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
describe "#call" do
|
107
|
+
before { terminal_middleware.uses(middleware_a) }
|
108
|
+
|
109
|
+
describe 'notifications' do
|
110
|
+
before { Coach::Notifications.subscribe! }
|
111
|
+
|
112
|
+
# Prevent RequestSerializer from erroring due to insufficient request mock
|
113
|
+
before do
|
114
|
+
allow(Coach::RequestSerializer).
|
115
|
+
to receive(:new).
|
116
|
+
and_return(double(serialize: {}))
|
117
|
+
end
|
118
|
+
|
119
|
+
subject(:coach_events) do
|
120
|
+
events = []
|
121
|
+
subscription =
|
122
|
+
ActiveSupport::Notifications.subscribe(/coach/) do |name, *args|
|
123
|
+
events << name
|
124
|
+
end
|
125
|
+
|
126
|
+
handler.call({})
|
127
|
+
ActiveSupport::Notifications.unsubscribe(subscription)
|
128
|
+
events
|
129
|
+
end
|
130
|
+
|
131
|
+
it { is_expected.to include('coach.handler.start') }
|
132
|
+
it { is_expected.to include('coach.middleware.start') }
|
133
|
+
it { is_expected.to include('coach.request') }
|
134
|
+
it { is_expected.to include('coach.middleware.finish') }
|
135
|
+
it { is_expected.to include('coach.handler.finish') }
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require "coach/middleware"
|
2
|
+
|
3
|
+
describe Coach::Middleware do
|
4
|
+
let(:middleware_class) { Class.new(Coach::Middleware) }
|
5
|
+
let(:context_) { {} }
|
6
|
+
let(:middleware_obj) { middleware_class.new(context_, nil) }
|
7
|
+
|
8
|
+
describe ".provides?" do
|
9
|
+
context "given names it does provide" do
|
10
|
+
before { middleware_class.provides(:foo, :bar) }
|
11
|
+
|
12
|
+
it "returns true" do
|
13
|
+
expect(middleware_class.provides?(:foo)).to be_truthy
|
14
|
+
expect(middleware_class.provides?(:bar)).to be_truthy
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
context "given names it doesn't provide" do
|
19
|
+
before { middleware_class.provides(:foo) }
|
20
|
+
|
21
|
+
it "returns false" do
|
22
|
+
expect(middleware_class.provides?(:baz)).to be_falsy
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
describe "#provide" do
|
28
|
+
before { middleware_class.provides(:foo) }
|
29
|
+
|
30
|
+
context "given a name it can provide" do
|
31
|
+
it "adds it to the context" do
|
32
|
+
expect { middleware_obj.provide(foo: "bar") }.
|
33
|
+
to change { context_ }.from({}).to(foo: "bar")
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
context "given a name it can't provide" do
|
38
|
+
it "blows up" do
|
39
|
+
expect { middleware_obj.provide(baz: "bar") }.to raise_error(NameError)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
require "coach/middleware_validator"
|
3
|
+
|
4
|
+
describe Coach::MiddlewareValidator do
|
5
|
+
subject(:validator) { described_class.new(head_middleware, already_provided) }
|
6
|
+
|
7
|
+
let(:head_middleware) { build_middleware('Head') }
|
8
|
+
let(:already_provided) { [] }
|
9
|
+
|
10
|
+
let(:middleware_a) { build_middleware('A') }
|
11
|
+
let(:middleware_b) { build_middleware('B') }
|
12
|
+
let(:middleware_c) { build_middleware('C') }
|
13
|
+
|
14
|
+
# head <── a
|
15
|
+
# └─ b <- c
|
16
|
+
before do
|
17
|
+
head_middleware.uses middleware_a
|
18
|
+
head_middleware.uses middleware_b
|
19
|
+
middleware_b.uses middleware_c
|
20
|
+
end
|
21
|
+
|
22
|
+
describe "#validated_provides!" do
|
23
|
+
subject { -> { validator.validated_provides! } }
|
24
|
+
|
25
|
+
context "with satisfied requires" do
|
26
|
+
context "one level deep" do
|
27
|
+
before do
|
28
|
+
head_middleware.requires :a
|
29
|
+
middleware_a.provides :a
|
30
|
+
end
|
31
|
+
|
32
|
+
it { is_expected.to_not raise_error }
|
33
|
+
end
|
34
|
+
|
35
|
+
context "that are inherited up" do
|
36
|
+
before do
|
37
|
+
head_middleware.requires :c
|
38
|
+
middleware_c.provides :c
|
39
|
+
end
|
40
|
+
it { is_expected.to_not raise_error }
|
41
|
+
end
|
42
|
+
|
43
|
+
# Middlewares should be able to use the keys provided by the items `used` before
|
44
|
+
# them. In this scenario, terminal will use a then b, and if b requires :a as a key
|
45
|
+
# then our dependencies should be satisfied.
|
46
|
+
context "that are inherited laterally" do
|
47
|
+
before do
|
48
|
+
middleware_a.provides :a
|
49
|
+
middleware_b.requires :a
|
50
|
+
end
|
51
|
+
it { is_expected.to_not raise_error }
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
context "with missing requirements" do
|
56
|
+
context "at terminal" do
|
57
|
+
before { head_middleware.requires :a, :c }
|
58
|
+
|
59
|
+
it { is_expected.to raise_exception(/requires keys \[a,c\]/) }
|
60
|
+
end
|
61
|
+
|
62
|
+
context "from unordered middleware" do
|
63
|
+
before do
|
64
|
+
middleware_a.requires :b
|
65
|
+
middleware_b.provides :b
|
66
|
+
end
|
67
|
+
|
68
|
+
it { is_expected.to raise_exception(/requires keys \[b\]/) }
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|