startback 0.4.5 → 0.5.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/startback/audit/trailer.rb +145 -0
  3. data/lib/startback/audit.rb +1 -0
  4. data/lib/startback/bus/bunny/async.rb +117 -0
  5. data/lib/startback/bus/bunny.rb +1 -0
  6. data/lib/startback/bus/memory/async.rb +40 -0
  7. data/lib/startback/bus/memory/sync.rb +30 -0
  8. data/lib/startback/bus/memory.rb +2 -0
  9. data/lib/startback/bus.rb +94 -0
  10. data/lib/startback/caching/entity_cache.rb +80 -0
  11. data/lib/startback/caching/store.rb +34 -0
  12. data/lib/startback/context/middleware.rb +1 -1
  13. data/lib/startback/context.rb +93 -4
  14. data/lib/startback/event.rb +43 -0
  15. data/lib/startback/operation.rb +39 -6
  16. data/lib/startback/support/fake_logger.rb +18 -0
  17. data/lib/startback/support/hooks.rb +48 -0
  18. data/lib/startback/support/operation_runner.rb +150 -0
  19. data/lib/startback/support/robustness.rb +153 -0
  20. data/lib/startback/support.rb +3 -0
  21. data/lib/startback/version.rb +2 -2
  22. data/lib/startback/web/api.rb +3 -4
  23. data/lib/startback/web/catch_all.rb +12 -5
  24. data/lib/startback/web/middleware.rb +13 -0
  25. data/lib/startback.rb +2 -0
  26. data/spec/spec_helper.rb +2 -0
  27. data/spec/unit/audit/test_trailer.rb +88 -0
  28. data/spec/unit/bus/memory/test_async.rb +41 -0
  29. data/spec/unit/bus/memory/test_sync.rb +41 -0
  30. data/spec/unit/caching/test_entity_cache.rb +109 -0
  31. data/spec/unit/context/test_abstraction_factory.rb +64 -0
  32. data/spec/unit/support/hooks/test_after_hook.rb +54 -0
  33. data/spec/unit/support/hooks/test_before_hook.rb +54 -0
  34. data/spec/unit/support/operation_runner/test_around_run.rb +157 -0
  35. data/spec/unit/support/operation_runner/test_before_after_call.rb +48 -0
  36. data/spec/unit/support/test_robusteness.rb +209 -0
  37. data/spec/unit/test_context.rb +51 -0
  38. data/spec/unit/test_event.rb +69 -0
  39. data/spec/unit/test_operation.rb +0 -3
  40. metadata +32 -4
@@ -0,0 +1,43 @@
1
+ module Startback
2
+ #
3
+ # An Event occuring a given context and having a type and attached data.
4
+ #
5
+ # Event instances have String types that are by default unrelated to ruby
6
+ # classes. Also, this Event class has a `json` information contract that
7
+ # allows dumping & reloading them easily. A context or context_factory may
8
+ # be provided in dress world to reload the event context from data, but
9
+ # that logic is opaque to this class.
10
+ #
11
+ # This class is intended to be subclassed if a more specific event protocol
12
+ # is wanted.
13
+ #
14
+ class Event
15
+
16
+ def initialize(type, data, context = nil)
17
+ @type = type.to_s
18
+ @data = OpenStruct.new(data)
19
+ @context = context
20
+ end
21
+ attr_reader :context, :type, :data
22
+
23
+ def self.json(src, world = {})
24
+ parsed = JSON.parse(src)
25
+ context = if world[:context]
26
+ world[:context]
27
+ elsif world[:context_factory]
28
+ world[:context_factory].call(parsed)
29
+ end
30
+ Event.new(parsed['type'], parsed['data'], context)
31
+ end
32
+
33
+ def to_json(*args, &bl)
34
+ h = {
35
+ type: self.type,
36
+ data: data.to_h
37
+ }
38
+ h[:context] = context if context
39
+ h.to_json(*args, &bl)
40
+ end
41
+
42
+ end # class Event
43
+ end # module Startback
@@ -1,16 +1,45 @@
1
1
  module Startback
2
+ #
3
+ # High-level Operation abstraction, that is a piece of code that executes
4
+ # on demand and (generally) changes the state of the software system.
5
+ #
6
+ # An operation is basically an object that respond to `call`, but that
7
+ # executes within a given world (see `bind`). It also has before and
8
+ # after hooks that allows specifying what needs to be done before invoking
9
+ # call and after having invoked it. All this protocol is actually under
10
+ # the responsibility of an `OperationRunner`. Operations should not be
11
+ # called manually by third-party code.
12
+ #
13
+ # Example:
14
+ #
15
+ # class SayHello < Startback::Operation
16
+ #
17
+ # before_call do
18
+ # # e.g. check_some_permissions
19
+ # end
20
+ #
21
+ # def call
22
+ # puts "Hello"
23
+ # end
24
+ #
25
+ # after_call do
26
+ # # e.g. log and/or emit something on a bus
27
+ # end
28
+ #
29
+ # end
30
+ #
2
31
  class Operation
3
32
  include Errors
33
+ include Support::OperationRunner
34
+ include Support::Hooks.new(:call)
4
35
 
5
36
  attr_accessor :world
6
-
7
37
  protected :world=
8
38
 
9
39
  def bind(world)
10
40
  return self unless world
11
- dup.tap{|op|
12
- op.world = world
13
- }
41
+ self.world = world
42
+ self
14
43
  end
15
44
 
16
45
  def method_missing(name, *args, &bl)
@@ -19,10 +48,14 @@ module Startback
19
48
  world.fetch(name){ super }
20
49
  end
21
50
 
51
+ def respond_to?(name, *args)
52
+ super || (world && world.has_key?(name))
53
+ end
54
+
22
55
  protected
23
56
 
24
- def run(operation)
25
- operation.bind(self.world).call
57
+ def operation_world(op)
58
+ self.world
26
59
  end
27
60
 
28
61
  end # class Operation
@@ -0,0 +1,18 @@
1
+ module Startback
2
+ module Support
3
+ class FakeLogger < Logger
4
+
5
+ def initialize(*args)
6
+ @last_msg = nil
7
+ end
8
+ attr_reader :last_msg
9
+
10
+ [:debug, :info, :warn, :error, :fatal].each do |meth|
11
+ define_method(meth) do |msg|
12
+ @last_msg = msg
13
+ end
14
+ end
15
+
16
+ end # class Logger
17
+ end # module Support
18
+ end # module Startback
@@ -0,0 +1,48 @@
1
+ module Startback
2
+ module Support
3
+ class Hooks < Module
4
+
5
+ def initialize(suffix)
6
+ @suffix = suffix
7
+ define_method :"before_#{suffix}" do
8
+ self.class.__befores.each do |bl|
9
+ instance_exec(&bl)
10
+ end
11
+ end
12
+ define_method :"after_#{suffix}" do
13
+ self.class.__afters.each do |bl|
14
+ instance_exec(&bl)
15
+ end
16
+ end
17
+ end
18
+ attr_reader :suffix
19
+
20
+ def included(by)
21
+ by.instance_eval %Q{
22
+ def __befores(create = false)
23
+ if create
24
+ @__befores ||= (superclass.respond_to?(:__befores, false) ? superclass.__befores.dup : [])
25
+ end
26
+ @__befores || (superclass.respond_to?(:__befores, false) ? superclass.__befores : [])
27
+ end
28
+
29
+ def __afters(create = false)
30
+ if create
31
+ @__afters ||= (superclass.respond_to?(:__afters, false) ? superclass.__afters.dup : [])
32
+ end
33
+ @__afters || (superclass.respond_to?(:__afters, false) ? superclass.__afters : [])
34
+ end
35
+
36
+ def before_#{suffix}(&bl)
37
+ __befores(true) << bl
38
+ end
39
+
40
+ def after_#{suffix}(&bl)
41
+ __afters(true) << bl
42
+ end
43
+ }
44
+ end
45
+
46
+ end # class Hooks
47
+ end # module Support
48
+ end # module Startback
@@ -0,0 +1,150 @@
1
+ module Startback
2
+ module Support
3
+ #
4
+ # Support module for high-level architectural components that
5
+ # execute operations as part of their logic, see e.g. Web::Api.
6
+ #
7
+ # This module contributes a `run` instance method that allows
8
+ # binding an operation with a world, and executing it while
9
+ # supporting around runners.
10
+ #
11
+ # Example:
12
+ #
13
+ # class HighLevelComponent
14
+ # include Startback::Support::OperationRunner
15
+ #
16
+ # def some_method
17
+ # # Runs the operation passed after some binding
18
+ # run SomeOperation.new
19
+ # end
20
+ #
21
+ # protected
22
+ #
23
+ # # Overriden to inject some extra world
24
+ # def operation_world(op)
25
+ # super(op).merge({ hello: "world" })
26
+ # end
27
+ #
28
+ # # Execute this around op
29
+ # around_run do |op, then_block|
30
+ # puts "About to run #{op.inspect}"
31
+ # then_block.call
32
+ # end
33
+ #
34
+ # # SomeClass#call will be called with the operation
35
+ # # as first parameter and a block as continuation
36
+ # around_run SomeClass.new
37
+ #
38
+ # end
39
+ #
40
+ module OperationRunner
41
+
42
+ # Contributes the hook DSL methods to classes that include
43
+ # the OperationRunner module
44
+ module ClassMethods
45
+
46
+ # Registers a callable to be executed around operation running.
47
+ #
48
+ # In its block form, the callable is `instance_exec`uted on the
49
+ # runner instance, with the operation passed as first parameter
50
+ # and a then_block callable as second parameter (continuation):
51
+ #
52
+ # around_run do |op,then_block|
53
+ # # do whatever you want with the op (already bounded)
54
+ # puts op.inspect
55
+ #
56
+ # # do not forget to call the continuation block
57
+ # then_block.call
58
+ # end
59
+ #
60
+ # With a parameter responding to `#call`, the latter is invoked
61
+ # with the operation as parameter and a block as continuation:
62
+ #
63
+ # class Arounder
64
+ #
65
+ # def call(op)
66
+ # # do whatever you want with the op (already bounded)
67
+ # puts op.inspect
68
+ #
69
+ # # do not forget to call the continuation block
70
+ # yield
71
+ # end
72
+ #
73
+ # end
74
+ #
75
+ def around_run(arounder = nil, &bl)
76
+ raise ArgumentError, "Arg or block required" unless arounder || bl
77
+ arounds(true) << [arounder || bl, arounder.nil?]
78
+ end
79
+
80
+ private
81
+
82
+ def arounds(create = false)
83
+ if create
84
+ @arounds ||= superclass.respond_to?(:arounds, true) \
85
+ ? superclass.send(:arounds, true).dup \
86
+ : []
87
+ end
88
+ @arounds || (superclass.respond_to?(:arounds, true) ? superclass.send(:arounds, true) : [])
89
+ end
90
+
91
+ end
92
+
93
+ # When included by a class/module, install the DSL methods
94
+ def self.included(by)
95
+ by.extend(ClassMethods)
96
+ end
97
+
98
+ # Runs `operation`, taking care of binding it and executing
99
+ # hooks.
100
+ #
101
+ # This method is NOT intended to be overriden. Use hooks and
102
+ # `operation_world` to impact default behavior.
103
+ def run(operation)
104
+ op_world = operation_world(operation)
105
+ op_bound = operation.bind(op_world)
106
+ _run_befores(op_bound)
107
+ r = _run_with_arounds(op_bound, self.class.send(:arounds))
108
+ _run_afters(op_bound)
109
+ r
110
+ end
111
+
112
+ protected
113
+
114
+ # Returns the world to use to bind an operation.
115
+ #
116
+ # The default implementation returns an empty hash. This is
117
+ # intended to be overriden by classes including this module.
118
+ def operation_world(op)
119
+ {}
120
+ end
121
+
122
+ private
123
+
124
+ def _run_befores(op_bound)
125
+ op_bound.before_call if op_bound.respond_to?(:before_call, true)
126
+ end
127
+
128
+ def _run_with_arounds(operation, arounds = [])
129
+ if arounds.empty?
130
+ operation.call
131
+ else
132
+ arounder, iexec = arounds.first
133
+ after_first = ->() {
134
+ _run_with_arounds(operation, arounds[1..-1])
135
+ }
136
+ if iexec
137
+ self.instance_exec(operation, after_first, &arounder)
138
+ else
139
+ arounder.call(self, operation, &after_first)
140
+ end
141
+ end
142
+ end
143
+
144
+ def _run_afters(op_bound)
145
+ op_bound.after_call if op_bound.respond_to?(:after_call, true)
146
+ end
147
+
148
+ end # module OperationRunner
149
+ end # module Support
150
+ end # module Startback
@@ -0,0 +1,153 @@
1
+ module Startback
2
+ module Support
3
+ #
4
+ # This module provides helper methods for robustness of a software design.
5
+ #
6
+ # It is included by main Startback abstractions, and can be included by
7
+ # specific software components who needs fine-tuning of monitoring, logging
8
+ # and error handling.
9
+ #
10
+ # All public methods here follow the following free args parameters:
11
+ #
12
+ # 1. First (& second) argument(s) form the log message.
13
+ #
14
+ # A full log message is a Hash having :op (required), :op_took (optional),
15
+ # and :op_data (optional) keys.
16
+ #
17
+ # If a String (or two) are used instead, a log message will be built taking
18
+ # the former as the executer (a class or instance) and the second as a method.
19
+ # `{ op: "executer#method" }`
20
+ #
21
+ # 2. The second (or third) argument should be a Logger instance, a Context,
22
+ # or an instance knowing its context. The best logger is extracted from it
23
+ # and used for actual logging.
24
+ #
25
+ # Examples:
26
+ #
27
+ # log(op: "hello", op_data: {foo: 12}) => logged as such on STDOUT
28
+ # log("A simple message") => { op: "A simple message" } on STDOUT
29
+ # log(Startback, "hello") => { op: "Startback#hello" } on STDOUT
30
+ # log(Event.new, "hello") => { op: "Event#hello" } on STDOUT
31
+ # log(self, context) => { op: "..." } on context's logger or STDOUT
32
+ # log(self, event) => { op: "..." } on event context's logger or STDOUT
33
+ # ...
34
+ #
35
+ module Robustness
36
+
37
+ # Included to avoid poluting the space of the including
38
+ # classes.
39
+ module Tools
40
+
41
+ def default_logger
42
+ @@default_logger ||= ::Logger.new(STDOUT)
43
+ from = caller.reject{|x| x =~ /lib\/startback/ }.first
44
+ @@default_logger.debug("Watch out, using default logger from: #{from}")
45
+ @@default_logger
46
+ end
47
+ module_function :default_logger
48
+
49
+ def logger_for(arg)
50
+ return arg if arg.is_a?(::Logger)
51
+ return arg.logger if arg.is_a?(Context) && arg.logger
52
+ return logger_for(arg.context) if arg.respond_to?(:context, false)
53
+ default_logger
54
+ end
55
+ module_function :logger_for
56
+
57
+ def parse_args(log_msg, method = nil, context = nil, extra = nil)
58
+ method, context, extra = nil, method, context unless method.is_a?(String)
59
+ context, extra = nil, context if context.is_a?(Hash) && extra.nil?
60
+ logger = logger_for(context) || logger_for(log_msg)
61
+ log_msg = if log_msg.is_a?(Hash)
62
+ log_msg.dup
63
+ elsif log_msg.is_a?(String)
64
+ log_msg = { op: "#{log_msg}#{method.nil? ? '' : '#'+method.to_s}" }
65
+ else
66
+ log_msg = log_msg.class unless log_msg.is_a?(Module)
67
+ log_msg = { op: "#{log_msg.name}##{method}" }
68
+ end
69
+ log_msg.merge!(extra) if extra
70
+ [ log_msg, logger ]
71
+ end
72
+ module_function :parse_args
73
+
74
+ [:debug, :info, :warn, :error, :fatal].each do |meth|
75
+ define_method(meth) do |args, extra = nil, &bl|
76
+ act_args = args + [extra]
77
+ log_msg, logger = parse_args(*act_args)
78
+ logger.send(meth, log_msg)
79
+ end
80
+ module_function(meth)
81
+ end
82
+
83
+ end # module Tools
84
+
85
+ # Logs a specific message with a given severity.
86
+ #
87
+ # Severity can be :debug, :info, :warn, :error or :fatal.
88
+ # The args must follow module's conventions, see above.
89
+ def log(severity, *args)
90
+ Tools.send(severity, args)
91
+ end
92
+
93
+ # Calls the block and monitors then log its execution time.
94
+ #
95
+ # The args must follow module's conventions, see above.
96
+ def monitor(*args, &bl)
97
+ result = nil
98
+ took = Benchmark.realtime {
99
+ result = bl.call
100
+ }
101
+ Tools.debug(args, op_took: took)
102
+ result
103
+ rescue => ex
104
+ Tools.error(args, error: ex)
105
+ raise
106
+ end
107
+
108
+ # Executes the block without letting errors propagate.
109
+ # Errors are logged, though. Nothing is logged if everything
110
+ # goes fine.
111
+ #
112
+ # The args must follow module's conventions, see above.
113
+ def stop_errors(*args, &bl)
114
+ result = nil
115
+ took = Benchmark.realtime {
116
+ result = bl.call
117
+ }
118
+ result
119
+ rescue => ex
120
+ Tools.error(args, op_took: took, error: ex)
121
+ nil
122
+ end
123
+
124
+ # Tries executing the block up to `n` times, until an attempt
125
+ # succeeds (then returning the result). Logs the first and last
126
+ # fatal error, if any.
127
+ #
128
+ # The args must follow module's conventions, see above.
129
+ def try_max_times(n, *args, &bl)
130
+ retried = 0
131
+ took = 0
132
+ begin
133
+ result = nil
134
+ took += Benchmark.realtime {
135
+ result = bl.call
136
+ }
137
+ result
138
+ rescue => ex
139
+ Tools.error(args + [{op_took: took, error: ex}]) if retried == 0
140
+ retried += 1
141
+ if retried < n
142
+ sleep(retried)
143
+ retry
144
+ else
145
+ Tools.fatal(args + [{op_took: took, error: ex}])
146
+ raise
147
+ end
148
+ end
149
+ end
150
+
151
+ end # module Robustness
152
+ end # module Support
153
+ end # module Startback
@@ -15,3 +15,6 @@ module Startback
15
15
  end # module Support
16
16
  end # module Startback
17
17
  require_relative 'support/logger'
18
+ require_relative 'support/robustness'
19
+ require_relative 'support/hooks'
20
+ require_relative 'support/operation_runner'
@@ -1,8 +1,8 @@
1
1
  module Startback
2
2
  module Version
3
3
  MAJOR = 0
4
- MINOR = 4
5
- TINY = 5
4
+ MINOR = 5
5
+ TINY = 0
6
6
  end
7
7
  VERSION = "#{Version::MAJOR}.#{Version::MINOR}.#{Version::TINY}"
8
8
  end
@@ -20,11 +20,10 @@ module Startback
20
20
  ###
21
21
  ### Facade over third party tools
22
22
  ###
23
+ include Support::OperationRunner
23
24
 
24
- def run(operation)
25
- operation
26
- .bind({ context: context })
27
- .call
25
+ def operation_world(op)
26
+ { context: context }
28
27
  end
29
28
 
30
29
  ###
@@ -19,6 +19,7 @@ module Startback
19
19
  #
20
20
  class CatchAll < Rack::Robustness
21
21
  include Errors
22
+ include Support::Robustness
22
23
 
23
24
  FATAL_ERROR = {
24
25
  code: "Startback::Errors::InternalServerError",
@@ -32,12 +33,18 @@ module Startback
32
33
  self.body FATAL_ERROR
33
34
 
34
35
  self.ensure(true) do |ex|
36
+ STDERR.puts(ex.message)
35
37
  context = env[Context::Middleware::RACK_ENV_KEY]
36
- if context && context.respond_to?(:error_handler) && context.error_handler
37
- context.error_handler.fatal(ex)
38
- else
39
- Startback::LOGGER.fatal(ex.message)
40
- Startback::LOGGER.fatal(ex.backtrace.join("\n"))
38
+ begin
39
+ if context && context.respond_to?(:error_handler, true) && context.error_handler
40
+ context.error_handler.fatal(ex)
41
+ else
42
+ log(:fatal, self, "ensure", error: ex)
43
+ end
44
+ rescue => ex2
45
+ STDERR.puts(ex2.message)
46
+ STDERR.puts(ex2.backtrace[0..10].join("\n"))
47
+ raise
41
48
  end
42
49
  end
43
50
 
@@ -0,0 +1,13 @@
1
+ module Startback
2
+ module Web
3
+ module Middleware
4
+
5
+ protected
6
+
7
+ def context(env = @env)
8
+ ::Startback::Context::Middleware.context(env) || Errors.server_error!("Unable to find context!!")
9
+ end
10
+
11
+ end # module Middleware
12
+ end # module Web
13
+ end # module Startback
data/lib/startback.rb CHANGED
@@ -3,6 +3,8 @@ require 'rack/robustness'
3
3
  require 'finitio'
4
4
  require 'logger'
5
5
  require 'path'
6
+ require 'ostruct'
7
+ require 'benchmark'
6
8
  # Provides a reusable backend framework for backend components written
7
9
  # in ruby.
8
10
  #
data/spec/spec_helper.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
2
2
  require 'startback'
3
+ require 'startback/bus'
4
+ require 'startback/support/fake_logger'
3
5
  require 'rack/test'
4
6
 
5
7
  module SpecHelpers
@@ -0,0 +1,88 @@
1
+ require 'spec_helper'
2
+ require 'startback/audit'
3
+ module Startback
4
+ module Audit
5
+ describe Trailer do
6
+
7
+ let(:trailer) {
8
+ Trailer.new("/tmp/trail.log")
9
+ }
10
+
11
+ describe "op_data" do
12
+
13
+ def op_data(op, trailer = self.trailer)
14
+ trailer.send(:op_data, op)
15
+ end
16
+
17
+ it 'uses to_trail in priority if provided' do
18
+ op = OpenStruct.new(to_trail: { foo: "bar" }, input: 12, request: 13)
19
+ expect(op_data(op)).to eql({ foo: "bar" })
20
+ end
21
+
22
+ it 'uses input then' do
23
+ op = OpenStruct.new(input: { foo: "bar" }, request: 13)
24
+ expect(op_data(op)).to eql({ foo: "bar" })
25
+ end
26
+
27
+ it 'uses request then' do
28
+ op = OpenStruct.new(request: { foo: "bar" })
29
+ expect(op_data(op)).to eql({ foo: "bar" })
30
+ end
31
+
32
+ it 'applies default blacklists for security reasons' do
33
+ op = OpenStruct.new(input: {
34
+ token: "will not be dumped",
35
+ a_token: "will not be dumped",
36
+ AToken: "will not be dumped",
37
+ password: "will not be dumped",
38
+ secret: "will not be dumped",
39
+ credentials: "will not be dumped",
40
+ foo: "bar"
41
+ })
42
+ expect(op_data(op)).to eql({
43
+ foo: "bar"
44
+ })
45
+ end
46
+
47
+ it 'applies default blacklists to data arrays too' do
48
+ op = OpenStruct.new(input: [{
49
+ token: "will not be dumped",
50
+ a_token: "will not be dumped",
51
+ AToken: "will not be dumped",
52
+ password: "will not be dumped",
53
+ secret: "will not be dumped",
54
+ credentials: "will not be dumped",
55
+ foo: "bar"
56
+ }])
57
+ expect(op_data(op)).to eql([{
58
+ foo: "bar"
59
+ }])
60
+ end
61
+
62
+ it 'uses the stop words provided at construction' do
63
+ t = Trailer.new("/tmp/trail.log", blacklist: "hello and world")
64
+ op = OpenStruct.new(request: { Hello: "bar", World: "foo", foo: "bar" })
65
+ expect(op_data(op, t)).to eql({ foo: "bar" })
66
+ end
67
+
68
+ end
69
+
70
+ describe "op_context" do
71
+
72
+ def op_context(op, trailer = self.trailer)
73
+ trailer.send(:op_context, op)
74
+ end
75
+
76
+ it 'applies default blacklists for security reasons' do
77
+ op = OpenStruct.new(context: {
78
+ token: "will not be dumped",
79
+ foo: "bar"
80
+ })
81
+ expect(op_context(op)).to eql({ foo: "bar" })
82
+ end
83
+
84
+ end
85
+
86
+ end
87
+ end
88
+ end