coach 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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