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.
@@ -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