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
data/coach.gemspec
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'coach/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'coach'
|
8
|
+
spec.version = Coach::VERSION
|
9
|
+
spec.date = '2015-06-12'
|
10
|
+
spec.summary = 'coach middleware'
|
11
|
+
spec.description = 'Controller framework'
|
12
|
+
spec.authors = %w(GoCardless)
|
13
|
+
spec.homepage = "https://github.com/gocardless/coach"
|
14
|
+
spec.email = %w(developers@gocardless.com)
|
15
|
+
spec.license = 'MIT'
|
16
|
+
|
17
|
+
spec.files = `git ls-files -z`.split("\x0")
|
18
|
+
spec.test_files = spec.files.grep(%r{^spec/})
|
19
|
+
spec.require_paths = ['lib']
|
20
|
+
|
21
|
+
spec.add_dependency 'actionpack', '~> 4.2'
|
22
|
+
spec.add_dependency 'activesupport', '~> 4.2'
|
23
|
+
|
24
|
+
spec.add_development_dependency 'rspec', '~> 3.2.0'
|
25
|
+
spec.add_development_dependency 'rspec-its', '~> 1.2.0'
|
26
|
+
spec.add_development_dependency 'pry'
|
27
|
+
spec.add_development_dependency 'rubocop'
|
28
|
+
end
|
data/lib/coach.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
require 'action_dispatch'
|
3
|
+
|
4
|
+
require_relative 'coach/errors'
|
5
|
+
require_relative 'coach/handler'
|
6
|
+
require_relative 'coach/middleware'
|
7
|
+
require_relative 'coach/middleware_item'
|
8
|
+
require_relative 'coach/middleware_validator'
|
9
|
+
require_relative 'coach/notifications'
|
10
|
+
require_relative 'coach/request_benchmark'
|
11
|
+
require_relative 'coach/request_serializer'
|
12
|
+
require_relative 'coach/router'
|
13
|
+
require_relative 'coach/version'
|
14
|
+
|
15
|
+
module Coach
|
16
|
+
end
|
data/lib/coach/errors.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
module Coach
|
2
|
+
module Errors
|
3
|
+
class MiddlewareDependencyNotMet < StandardError
|
4
|
+
def initialize(middleware, keys)
|
5
|
+
super("#{middleware.name} requires keys [#{keys.join(',')}] that are not " \
|
6
|
+
"provided by the middleware chain")
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
class RouterUnknownDefaultAction < StandardError
|
11
|
+
def initialize(action)
|
12
|
+
super("Coach::Router does not know how to build action :#{action}")
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require "coach/errors"
|
2
|
+
|
3
|
+
module Coach
|
4
|
+
class Handler
|
5
|
+
def initialize(middleware)
|
6
|
+
@root_item = MiddlewareItem.new(middleware)
|
7
|
+
validate!
|
8
|
+
end
|
9
|
+
|
10
|
+
# Run validation on the root of the middleware chain
|
11
|
+
delegate :validate!, to: :@root_item
|
12
|
+
|
13
|
+
# The Rack interface to handler - builds a middleware chain based on
|
14
|
+
# the current request, and invokes it.
|
15
|
+
def call(env)
|
16
|
+
context = { request: ActionDispatch::Request.new(env) }
|
17
|
+
sequence = build_sequence(@root_item, context)
|
18
|
+
chain = build_request_chain(sequence, context)
|
19
|
+
|
20
|
+
start_event = start_event(context)
|
21
|
+
start = Time.now
|
22
|
+
|
23
|
+
publish('coach.handler.start', start_event.dup)
|
24
|
+
response = chain.instrument.call
|
25
|
+
|
26
|
+
finish = Time.now
|
27
|
+
publish('coach.handler.finish',
|
28
|
+
start, finish, nil, start_event.merge(response: { status: response[0] }))
|
29
|
+
|
30
|
+
response
|
31
|
+
end
|
32
|
+
|
33
|
+
# Traverse the middlware tree to build a linear middleware sequence,
|
34
|
+
# containing only middlewares that apply to this request.
|
35
|
+
def build_sequence(item, context)
|
36
|
+
sub_sequence = item.middleware.middleware_dependencies
|
37
|
+
filtered_sub_sequence = filter_sequence(sub_sequence, context)
|
38
|
+
flattened_sub_sequence = filtered_sub_sequence.flat_map do |child_item|
|
39
|
+
build_sequence(child_item, context)
|
40
|
+
end
|
41
|
+
|
42
|
+
dedup_sequence(flattened_sub_sequence + [item])
|
43
|
+
end
|
44
|
+
|
45
|
+
# Given a middleware sequence, filter out items not applicable to the
|
46
|
+
# current request, and set up a chain of instantiated middleware objects,
|
47
|
+
# ready to serve a request.
|
48
|
+
def build_request_chain(sequence, context)
|
49
|
+
chain_items = filter_sequence(sequence, context)
|
50
|
+
chain_items.reverse.reduce(nil) do |successor, item|
|
51
|
+
item.build_middleware(context, successor)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
# Trigger ActiveSupport::Notification
|
58
|
+
def publish(name, *args)
|
59
|
+
ActiveSupport::Notifications.publish(name, *args)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Remove middleware that have been included multiple times with the same
|
63
|
+
# config, leaving only the first instance
|
64
|
+
def dedup_sequence(sequence)
|
65
|
+
sequence.uniq { |item| [item.class, item.middleware, item.config] }
|
66
|
+
end
|
67
|
+
|
68
|
+
# Filter out middleware items that don't apply to this request - i.e. those
|
69
|
+
# that have defined an `if` condition that doesn't match this context.
|
70
|
+
def filter_sequence(sequence, context)
|
71
|
+
sequence.select { |item| item.use_with_context?(context) }
|
72
|
+
end
|
73
|
+
|
74
|
+
# Event to send for start of handler
|
75
|
+
def start_event(context)
|
76
|
+
{
|
77
|
+
middleware: @root_item.middleware.name,
|
78
|
+
request: context[:request]
|
79
|
+
}
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
require "coach/middleware_item"
|
2
|
+
|
3
|
+
module Coach
|
4
|
+
class Middleware
|
5
|
+
def self.uses(middleware, config = {})
|
6
|
+
middleware_dependencies << MiddlewareItem.new(middleware, config)
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.middleware_dependencies
|
10
|
+
@middleware_dependencies ||= []
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.provided
|
14
|
+
@provided ||= []
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.provides(*new_provided)
|
18
|
+
provided.concat(new_provided)
|
19
|
+
provided.uniq!
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.provides?(requirement)
|
23
|
+
provided.include?(requirement)
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.requirements
|
27
|
+
@requirements ||= []
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.requires(*new_requirements)
|
31
|
+
requirements.concat(new_requirements)
|
32
|
+
requirements.uniq!
|
33
|
+
|
34
|
+
new_requirements.each do |requirement|
|
35
|
+
define_method(requirement) { @_context[requirement] }
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
attr_reader :next_middleware, :config
|
40
|
+
|
41
|
+
# Middleware gets access to a shared context, which is populated by other
|
42
|
+
# middleware futher up the stack, a reference to the next middleware in
|
43
|
+
# the stack, and a config object.
|
44
|
+
def initialize(context, next_middleware = nil, config = {})
|
45
|
+
@_context = context
|
46
|
+
@next_middleware = next_middleware
|
47
|
+
@config = config
|
48
|
+
end
|
49
|
+
|
50
|
+
# `request` is always present in context, and we want to give every
|
51
|
+
# middleware access to it by default as it's always present and often used!
|
52
|
+
def request
|
53
|
+
@_context[:request]
|
54
|
+
end
|
55
|
+
|
56
|
+
# Make values available to middleware further down the stack. Accepts a
|
57
|
+
# hash of name => value pairs. Names must have been declared by calling
|
58
|
+
# `provides` on the class.
|
59
|
+
def provide(args)
|
60
|
+
args.each do |name, value|
|
61
|
+
unless self.class.provides?(name)
|
62
|
+
raise NameError, "#{self.class} does not provide #{name}"
|
63
|
+
end
|
64
|
+
|
65
|
+
@_context[name] = value
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Use ActiveSupport to instrument the execution of the subsequent chain.
|
70
|
+
def instrument
|
71
|
+
proc do
|
72
|
+
ActiveSupport::Notifications.
|
73
|
+
publish('coach.middleware.start', middleware_event)
|
74
|
+
ActiveSupport::Notifications.
|
75
|
+
instrument('coach.middleware.finish', middleware_event) { call }
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Helper to access request params from within middleware
|
80
|
+
delegate :params, to: :request
|
81
|
+
|
82
|
+
# Most of the time this will be overridden, but by default we just call
|
83
|
+
# the next middleware in the chain.
|
84
|
+
delegate :call, to: :next_middleware
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
# Event for ActiveSupport
|
89
|
+
def middleware_event
|
90
|
+
{
|
91
|
+
middleware: self.class.name,
|
92
|
+
request: request
|
93
|
+
}
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require "coach/errors"
|
2
|
+
require "coach/middleware_validator"
|
3
|
+
|
4
|
+
module Coach
|
5
|
+
class MiddlewareItem
|
6
|
+
attr_accessor :middleware, :config
|
7
|
+
|
8
|
+
def initialize(middleware, config = {})
|
9
|
+
@middleware = middleware
|
10
|
+
@config = config
|
11
|
+
end
|
12
|
+
|
13
|
+
def build_middleware(context, successor)
|
14
|
+
@middleware.new(context, successor && successor.instrument, @config)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Requires tweaking to make it run methods by symbol on the class from which the
|
18
|
+
# `uses` call is made.
|
19
|
+
def use_with_context?(context)
|
20
|
+
return true if @config[:if].nil?
|
21
|
+
return @config[:if].call(context) if @config[:if].respond_to?(:call)
|
22
|
+
middleware.send(@config[:if], context)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Runs validation against the middleware chain, raising if any unmet dependencies are
|
26
|
+
# discovered.
|
27
|
+
def validate!
|
28
|
+
MiddlewareValidator.new(middleware).validated_provides!
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
require "coach/errors"
|
2
|
+
|
3
|
+
module Coach
|
4
|
+
class MiddlewareValidator
|
5
|
+
def initialize(middleware, already_provided = [])
|
6
|
+
@middleware = middleware
|
7
|
+
@already_provided = already_provided
|
8
|
+
end
|
9
|
+
|
10
|
+
# Aggregates provided keys from the given middleware, and all the middleware it uses.
|
11
|
+
# Can raise at any level assuming a used middleware is missing a required dependency.
|
12
|
+
def validated_provides!
|
13
|
+
if missing_requirements.any?
|
14
|
+
raise Coach::Errors::MiddlewareDependencyNotMet.new(
|
15
|
+
@middleware, missing_requirements
|
16
|
+
)
|
17
|
+
end
|
18
|
+
|
19
|
+
@middleware.provided + provided_by_chain
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def missing_requirements
|
25
|
+
@middleware.requirements - provided_by_chain
|
26
|
+
end
|
27
|
+
|
28
|
+
def provided_by_chain
|
29
|
+
@provided_by_chain ||=
|
30
|
+
middleware_dependencies.reduce(@already_provided) do |provided, middleware|
|
31
|
+
provided + self.class.new(middleware, provided).validated_provides!
|
32
|
+
end.flatten.uniq
|
33
|
+
end
|
34
|
+
|
35
|
+
def middleware_dependencies
|
36
|
+
@middleware_dependencies ||= @middleware.middleware_dependencies.map(&:middleware)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
require_relative 'request_benchmark'
|
2
|
+
require_relative 'request_serializer'
|
3
|
+
|
4
|
+
module Coach
|
5
|
+
# By default, Coach will trigger ActiveSupport::Notifications at specific times in a
|
6
|
+
# request lifecycle.
|
7
|
+
#
|
8
|
+
# Notifications is used to coordinate the listening and aggregation of these middleware
|
9
|
+
# notifications, while RequestEvent processes the published data.
|
10
|
+
#
|
11
|
+
# Once a request has completed, Notifications will emit a 'coach.request' with
|
12
|
+
# aggregated request data.
|
13
|
+
class Notifications
|
14
|
+
# Begin processing/emitting 'coach.request's
|
15
|
+
def self.subscribe!
|
16
|
+
instance.subscribe!
|
17
|
+
end
|
18
|
+
|
19
|
+
# Cease to emit 'coach.request's
|
20
|
+
def self.unsubscribe!
|
21
|
+
instance.unsubscribe!
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.instance
|
25
|
+
@instance ||= new
|
26
|
+
end
|
27
|
+
|
28
|
+
def subscribe!
|
29
|
+
return if active?
|
30
|
+
|
31
|
+
@subscriptions << subscribe('handler.start') do |_, event|
|
32
|
+
@benchmarks[event[:request].uuid] = RequestBenchmark.new(event[:middleware])
|
33
|
+
end
|
34
|
+
|
35
|
+
@subscriptions << subscribe('middleware.finish') do |name, start, finish, _, event|
|
36
|
+
log_middleware_finish(event, start, finish)
|
37
|
+
end
|
38
|
+
|
39
|
+
@subscriptions << subscribe('handler.finish') do |name, start, finish, _, event|
|
40
|
+
log_handler_finish(event, start, finish)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def unsubscribe!
|
45
|
+
return unless active?
|
46
|
+
while @subscriptions.any?
|
47
|
+
ActiveSupport::Notifications.unsubscribe(@subscriptions.pop)
|
48
|
+
end
|
49
|
+
true
|
50
|
+
end
|
51
|
+
|
52
|
+
def active?
|
53
|
+
@subscriptions.any?
|
54
|
+
end
|
55
|
+
|
56
|
+
private_class_method :new
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def initialize
|
61
|
+
@benchmarks = {}
|
62
|
+
@subscriptions = []
|
63
|
+
end
|
64
|
+
|
65
|
+
def subscribe(event, &block)
|
66
|
+
ActiveSupport::Notifications.subscribe("coach.#{event}", &block)
|
67
|
+
end
|
68
|
+
|
69
|
+
def log_middleware_finish(event, start, finish)
|
70
|
+
benchmark_for_request = @benchmarks[event[:request].uuid]
|
71
|
+
return unless benchmark_for_request.present?
|
72
|
+
benchmark_for_request.notify(event[:middleware], start, finish)
|
73
|
+
end
|
74
|
+
|
75
|
+
def log_handler_finish(event, start, finish)
|
76
|
+
benchmark = @benchmarks.delete(event[:request].uuid)
|
77
|
+
benchmark.complete(start, finish)
|
78
|
+
broadcast(event, benchmark)
|
79
|
+
end
|
80
|
+
|
81
|
+
def broadcast(event, benchmark)
|
82
|
+
serialized = RequestSerializer.new(event[:request]).serialize.
|
83
|
+
merge(benchmark.stats).
|
84
|
+
merge(event.slice(:response))
|
85
|
+
ActiveSupport::Notifications.publish('coach.request', serialized)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module Coach
|
2
|
+
# This class is built to aggregate data during the course of the request. It relies on
|
3
|
+
# 'coach.middleware.start' and 'coach.middleware.end' events to register the
|
4
|
+
# start/end of each middleware element, and thereby calculate running times for each.
|
5
|
+
#
|
6
|
+
# Coach::Notifications makes use of this class to produce benchmark data for
|
7
|
+
# requests.
|
8
|
+
class RequestBenchmark
|
9
|
+
def initialize(endpoint_name)
|
10
|
+
@endpoint_name = endpoint_name
|
11
|
+
@events = []
|
12
|
+
end
|
13
|
+
|
14
|
+
def notify(name, start, finish)
|
15
|
+
event = { name: name, start: start, finish: finish }
|
16
|
+
|
17
|
+
duration_of_children = child_events_for(event).
|
18
|
+
inject(0) { |total, e| total + e[:duration] }
|
19
|
+
event[:duration] = (finish - start) - duration_of_children
|
20
|
+
|
21
|
+
@events.push(event)
|
22
|
+
end
|
23
|
+
|
24
|
+
def complete(start, finish)
|
25
|
+
@duration = finish - start
|
26
|
+
end
|
27
|
+
|
28
|
+
# Serialize the results of the benchmarking
|
29
|
+
def stats
|
30
|
+
{
|
31
|
+
endpoint_name: @endpoint_name,
|
32
|
+
duration: format_ms(@duration),
|
33
|
+
chain: sorted_chain.map do |event|
|
34
|
+
{ name: event[:name], duration: format_ms(event[:duration]) }
|
35
|
+
end
|
36
|
+
}
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def previous_event
|
42
|
+
@events.last
|
43
|
+
end
|
44
|
+
|
45
|
+
def child_events_for(parent)
|
46
|
+
@events.select do |child|
|
47
|
+
parent[:start] < child[:start] && child[:finish] < parent[:finish]
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def sorted_chain
|
52
|
+
@events.sort_by { |event| event[:start] }
|
53
|
+
end
|
54
|
+
|
55
|
+
def format_ms(duration)
|
56
|
+
(1000 * duration).round
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|