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