coach 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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,3 @@
1
+ module Coach
2
+ VERSION = '0.0.0'
3
+ 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