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