coach 0.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/.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
|