appydays 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: eab73741b9b38b5e71b173859d4a21da866a0ed0d6ac2f2d336c535b60772cad
4
+ data.tar.gz: c801ace1467194743e5c30c2a08b91f6f735fc61cde35cbc142cae35267eaef0
5
+ SHA512:
6
+ metadata.gz: dad5bd7318ec9bfe7cf73011da874787a9b38dd60305944ea3024473bf3e4eca3aaba59ad0878b6bd37f9bd65c4ffb941e88219a3bfbbd029567d1edc33ebb2d
7
+ data.tar.gz: 8cfb4768365e9e1c5b1b0052be6b42246bfac6379e712903ef7ff76cf5e882e7d593c65757ac34673704f3b78b0800a25adc6dff578f5e64b75c2a37d6a0daa6
data/README.md ADDED
File without changes
data/lib/appydays.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydays
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "appydays/version"
4
+
5
+ ##
6
+ # Define the configuration for a module or class.
7
+ #
8
+ # Usage:
9
+ # include Appydays::Configurable
10
+ # configurable(:myapp) do
11
+ # setting :log_level, 'debug', env: 'LOG_LEVEL'
12
+ # setting :app_url, 'http://localhost:3000'
13
+ # setting :workers, 4
14
+ # end
15
+ #
16
+ # The module or class that extends Configurable will get two singleton methods,
17
+ # `log_level` and `app_url`.
18
+ # The first will be be given a value of `ENV.fetch('LOG_LEVEL', 'debug')`,
19
+ # because the env key is provided.
20
+ #
21
+ # The second will be given a value of `ENV.fetch('MYAPP_APP_URL', 'http:://localhost:3000')`,
22
+ # because the env key is defaulted.
23
+ #
24
+ # The second will be given a value of `4.class(ENV.fetch('MYAPP_WORKERS', 4))`.
25
+ # Note it will coerce the type of the env value to the type of the default.
26
+ # Empty strings will be coerced to nil.
27
+ #
28
+ # The `setting` method has several other options;
29
+ # see its documentation for more details.
30
+ #
31
+ # The target will also get a `reset_configuration` method that will restore defaults,
32
+ # and `run_after_configured_hooks`. See their docs for more details.
33
+ #
34
+ module Appydays::Configurable
35
+ def self.included(target)
36
+ target.extend(ClassMethods)
37
+ end
38
+
39
+ module ClassMethods
40
+ def configurable(key, &block)
41
+ raise LocalJumpError unless block
42
+
43
+ installer = Installer.new(self, key)
44
+
45
+ self.define_singleton_method(:_configuration_installer) { installer }
46
+
47
+ installer.instance_eval(&block)
48
+ installer._run_after_configured
49
+ end
50
+
51
+ ##
52
+ # Restore all settings back to the values they were at config time.
53
+ # Ie, undoes any manual attribute writes.
54
+ def reset_configuration
55
+ self._configuration_installer._reset
56
+ end
57
+
58
+ ##
59
+ # Explicitly run after configuration hooks.
60
+ # This may need to be run explicitly after reset,
61
+ # if the `after_configured` hook involves side effects.
62
+ # Side effects are gnarly so we don't make assumptions.
63
+ def run_after_configured_hooks
64
+ self._configuration_installer._run_after_configured
65
+ end
66
+ end
67
+
68
+ class Installer
69
+ def initialize(target, group_key)
70
+ @target = target
71
+ @group_key = group_key
72
+ @settings = {}
73
+ @after_config_hooks = []
74
+ end
75
+
76
+ ##
77
+ # Define a setting for the receiver,
78
+ # which acts as an attribute accessor.
79
+ #
80
+ # Params:
81
+ #
82
+ # name: The name of the accessor/setting.
83
+ # default: The default value.
84
+ # If `convert` is not supplied, this must be nil, or a string, int, float, or boolean,
85
+ # so the parsed environment value can be converted/coerced into the same type as 'default'.
86
+ # If convert is passed, that is used as the converter so the default value can be any type.
87
+ # key: The key to lookup the config value from the environment.
88
+ # If nil, use an auto-generated combo of the configuration key and method name.
89
+ # convert: If provided, call it with the string value so it can be parsed.
90
+ # For example, you can parse a string JSON value here.
91
+ # Convert will not be called with the default value.
92
+ # side_effect: If this setting should have a side effect,
93
+ # like configuring an external system, it should happen in this proc/lambda.
94
+ # It is called with the parsed/processed config value.
95
+ #
96
+ # Note that only ONE conversion will happen, and
97
+ # - If converter is provided, it will be used with the environment value.
98
+ def setting(name, default, key: nil, convert: nil, side_effect: nil)
99
+ installer = self
100
+
101
+ @target.define_singleton_method(name) do
102
+ self.class_variable_get("@@#{name}")
103
+ end
104
+ @target.define_singleton_method("#{name}=".to_sym) do |v|
105
+ installer._set_value(name, v, side_effect)
106
+ end
107
+
108
+ key ||= "#{@group_key}_#{name}".upcase
109
+ env_value = ENV.fetch(key, nil)
110
+ converter = self._converter(default, convert)
111
+ value = env_value.nil? ? default : converter[env_value]
112
+ value = installer._set_value(name, value, side_effect)
113
+ @settings[name] = value
114
+ end
115
+
116
+ def _set_value(name, value, side_effect)
117
+ value = nil if value == ""
118
+ # rubocop:disable Style/ClassVars
119
+ self._target.class_variable_set("@@#{name}", value)
120
+ # rubocop:enable Style/ClassVars
121
+ self._target.instance_exec(value, &side_effect) if side_effect
122
+ return value
123
+ end
124
+
125
+ def after_configured(&block)
126
+ @after_config_hooks << block
127
+ end
128
+
129
+ def _converter(default, converter)
130
+ return converter if converter
131
+
132
+ return ->(v) { v.to_s } if default.nil? || default.is_a?(String)
133
+ return ->(v) { v.to_i } if default.is_a?(Integer)
134
+ return ->(v) { v.to_f } if default.is_a?(Float)
135
+ return ->(v) { v.casecmp("true").zero? } if [TrueClass, FalseClass].include?(default.class)
136
+ raise TypeError, "Uncoercable type %p" % [default.class]
137
+ end
138
+
139
+ def _target
140
+ return @target
141
+ end
142
+
143
+ def _group_key
144
+ return @group_key
145
+ end
146
+
147
+ def _run_after_configured
148
+ @after_config_hooks.each do |h|
149
+ @target.instance_eval(&h)
150
+ end
151
+ end
152
+
153
+ def _reset
154
+ @settings.each do |k, v|
155
+ @target.send("#{k}=".to_sym, v)
156
+ end
157
+ self._run_after_configured
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dotenv"
4
+
5
+ require "appydays/version"
6
+
7
+ ##
8
+ # Wrapper over dotenv that will load the standard .env files for an environment
9
+ # (by convention, .env.<env>.local, .env.<env>, and .env).
10
+ #
11
+ # It can be called multiple times for the same environment.
12
+ #
13
+ # NOTE: Foreman assigns the $PORT environment variable BEFORE we load config
14
+ # (get to what is defined in worker, like puma.rb), so even if we have it in the .env files,
15
+ # it won't get used, because .env files don't stomp what is already in the environment
16
+ # (we don't want to use `overload`).
17
+ # So we have some trickery to overwrite only PORT.
18
+ module Appydays::Dotenviable
19
+ def self.load(rack_env: nil, default_rack_env: "development", env: ENV)
20
+ original_port = env.delete("PORT")
21
+ rack_env ||= env["RACK_ENV"] || default_rack_env
22
+ paths = [
23
+ ".env.#{rack_env}.local",
24
+ ".env.#{rack_env}",
25
+ ".env",
26
+ ]
27
+ Dotenv.load(*paths)
28
+ env["PORT"] ||= original_port
29
+ end
30
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "semantic_logger"
4
+
5
+ require "appydays/version"
6
+
7
+ ##
8
+ # Override SemanticLogger's keys for tags, named_tags, and payload.
9
+ # It is emminently unhelpful to have three places the same data may be-
10
+ # ie, in some cases, we may have a customer_id in a named tag,
11
+ # sometimes in payload, etc. Callsite behavior should not vary
12
+ # the shape/content of the log message.
13
+ class SemanticLogger::Formatters::Raw
14
+ alias original_call call
15
+
16
+ def call(log, logger)
17
+ h = self.original_call(log, logger)
18
+ ctx = h[:context] ||= {}
19
+ ctx[:_tags] = h.delete(:tags) if h.key?(:tags)
20
+
21
+ [:named_tags, :payload].each do |hash_key|
22
+ next unless h.key?(hash_key)
23
+ h.delete(hash_key).each do |k, v|
24
+ ctx[k] = v
25
+ end
26
+ end
27
+
28
+ return h
29
+ end
30
+ end
31
+
32
+ ##
33
+ # Helpers for working with structured logging.
34
+ # Use this instead of calling semantic_logger directly.
35
+ # Generally you `include Appydays::Loggable`
36
+ module Appydays::Loggable
37
+ def self.included(target)
38
+ target.include(SemanticLogger::Loggable)
39
+
40
+ target.extend(Methods)
41
+ target.include(Methods)
42
+ end
43
+
44
+ def self.default_level=(v)
45
+ self.set_default_level(v)
46
+ end
47
+
48
+ def self.set_default_level(v, warning: true)
49
+ return if v == SemanticLogger.default_level
50
+ self[self].warn "Overriding log level to %p" % v if warning
51
+ SemanticLogger.default_level = v
52
+ end
53
+
54
+ ##
55
+ # Return the logger for a key/object.
56
+ def self.[](key)
57
+ return key.logger if key.respond_to?(:logger)
58
+ (key = key.class) unless [Module, Class].include?(key.class)
59
+ return SemanticLogger[key]
60
+ end
61
+
62
+ ##
63
+ # Configure logging for 12 factor applications.
64
+ # Specifically, that means setting STDOUT to synchronous,
65
+ # using STDOUT as the log output,
66
+ # and also conveniently using color formatting if using a tty or json otherwise
67
+ # (ie, you want to use json logging on a server).
68
+ def self.configure_12factor(format: nil, application: nil)
69
+ format ||= $stdout.isatty ? :color : :json
70
+ $stdout.sync = true
71
+ SemanticLogger.application = application if application
72
+ SemanticLogger.add_appender(io: $stdout, formatter: format.to_sym)
73
+ end
74
+
75
+ def self.with_log_tags(tags, &block)
76
+ return SemanticLogger.named_tagged(tags, &block)
77
+ end
78
+
79
+ @stderr_appended = false
80
+
81
+ def self.ensure_stderr_appender
82
+ return if @stderr_appended
83
+ SemanticLogger.add_appender(io: $stderr)
84
+ @stderr_appended = true
85
+ end
86
+
87
+ module Methods
88
+ def with_log_tags(tags, &block)
89
+ return SemanticLogger.named_tagged(tags, &block)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "securerandom"
5
+
6
+ require "appydays/loggable"
7
+
8
+ ##
9
+ # Rack middleware for request logging, to replace Rack::CommonLogger.
10
+ # NOTE: To get additional log fields, you can subclass this and override `_request_tags`.
11
+ # If you want authenticated user info, make sure you install the middleware
12
+ # after your auth middleware.
13
+ #
14
+ # Params:
15
+ #
16
+ # error_code: If an unhandled exception reaches the request logger.
17
+ # or happens in the request logger, this status code will be used for easay identification.
18
+ # Generally unhandled exceptions in an application should be handled further downstream,
19
+ # and already be returning the right error code.
20
+ # slow_request_seconds: Normally request_finished is logged at info.
21
+ # Requests that take >= this many seconds are logged at warn.
22
+ # reraise: Reraise unhandled errors (after logging them).
23
+ # In some cases you may want this to be true, but in some it may bring down the whole process.
24
+ class Appydays::Loggable::RequestLogger
25
+ include Appydays::Loggable
26
+
27
+ def initialize(app, error_code: 599, slow_request_seconds: 10, reraise: true)
28
+ @app = app
29
+ @error_code = error_code
30
+ @slow_request_seconds = slow_request_seconds
31
+ @reraise = reraise
32
+ end
33
+
34
+ def call(env)
35
+ began_at = Time.now
36
+ request_fields = self._request_tags(env, began_at)
37
+ status, header, body = SemanticLogger.named_tagged(request_fields) { @app.call(env) }
38
+ header = Rack::Utils::HeaderHash.new(header)
39
+ body = Rack::BodyProxy.new(body) { self.log_finished(request_fields, began_at, status, header) }
40
+ [status, header, body]
41
+ rescue StandardError => e
42
+ began_at ||= nil
43
+ request_fields ||= {}
44
+ self.log_finished(request_fields, began_at, 599, {}, e)
45
+ raise if @reraise
46
+ end
47
+
48
+ protected def _request_tags(env, began_at)
49
+ req_id = SecureRandom.uuid.to_s
50
+ env["HTTP_TRACE_ID"] ||= req_id
51
+ return {
52
+ remote_addr: env["HTTP_X_FORWARDED_FOR"] || env["REMOTE_ADDR"] || "-",
53
+ request_started_at: began_at.to_s,
54
+ request_method: env[Rack::REQUEST_METHOD],
55
+ request_path: env[Rack::PATH_INFO],
56
+ request_query: env.fetch(Rack::QUERY_STRING, "").empty? ? "" : "?#{env[Rack::QUERY_STRING]}",
57
+ request_id: req_id,
58
+ trace_id: env["HTTP_TRACE_ID"],
59
+ }.merge(self.request_tags(env))
60
+ end
61
+
62
+ def request_tags(_env)
63
+ return {}
64
+ end
65
+
66
+ protected def log_finished(request_tags, began_at, status, header, exc=nil)
67
+ elapsed = (Time.now - began_at).to_f
68
+
69
+ all_tags = request_tags.merge(
70
+ response_finished_at: Time.now.to_s,
71
+ response_status: status,
72
+ response_content_length: extract_content_length(header),
73
+ response_ms: elapsed * 1000,
74
+ )
75
+
76
+ level = if status >= 500
77
+ "error"
78
+ elsif elapsed >= @slow_request_seconds
79
+ "warn"
80
+ else
81
+ "info"
82
+ end
83
+
84
+ self.logger.tagged(all_tags) do
85
+ self.logger.send(level, "request_finished", exc)
86
+ end
87
+ SemanticLogger.flush
88
+ end
89
+
90
+ protected def extract_content_length(headers)
91
+ value = headers[Rack::CONTENT_LENGTH]
92
+ return !value || value.to_s == "0" ? "-" : value
93
+ end
94
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patch Sequel::Database logging methods for structured logging
4
+ require "sequel/database/logging"
5
+
6
+ class Sequel::Database
7
+ def log_exception(exception, message)
8
+ log_each(
9
+ :error,
10
+ proc { "#{exception.class}: #{exception.message.strip if exception.message}: #{message}" },
11
+ proc { ["sequel_exception", {sequel_message: message}, exception] },
12
+ )
13
+ end
14
+
15
+ # Log a message at level info to all loggers.
16
+ def log_info(message, args=nil)
17
+ log_each(
18
+ :info,
19
+ proc { args ? "#{message}; #{args.inspect}" : message },
20
+ proc { ["sequel_log", {message: message, args: args}] },
21
+ )
22
+ end
23
+
24
+ # Log message with message prefixed by duration at info level, or
25
+ # warn level if duration is greater than log_warn_duration.
26
+ def log_duration(duration, message)
27
+ lwd = log_warn_duration
28
+ log_each(
29
+ lwd && (duration >= lwd) ? :warn : sql_log_level,
30
+ proc { "(#{'%0.6fs' % duration}) #{message}" },
31
+ proc { ["sequel_query", {duration: duration, query: message}] },
32
+ )
33
+ end
34
+
35
+ def log_each(level, std, semantic)
36
+ @loggers.each do |logger|
37
+ if logger.is_a?(SemanticLogger::Base)
38
+ logger.public_send(level, *semantic.call)
39
+ else
40
+ logger.public_send(level, std.call)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "appydays/loggable"
4
+
5
+ module Appydays::Loggable::SpecHelpers
6
+ def self.included(context)
7
+ # Appydays::Loggable.ensure_stderr_appender
8
+
9
+ context.around(:each) do |example|
10
+ override_level = (example.metadata[:log] || example.metadata[:logging])
11
+ if override_level
12
+ orig_level = SemanticLogger.default_level
13
+ SemanticLogger.default_level = override_level
14
+ end
15
+ example.run
16
+ SemanticLogger.default_level = orig_level if override_level
17
+ end
18
+
19
+ context.before(:all) do
20
+ Appydays::Loggable.set_default_level(:fatal, warning: false)
21
+ end
22
+
23
+ context.after(:all) do
24
+ Appydays::Loggable.set_default_level(:fatal, warning: false)
25
+ end
26
+
27
+ super
28
+ end
29
+
30
+ def capture_logs_from(loggers, level: "debug", formatter: nil)
31
+ (loggers = [loggers]) unless loggers.respond_to?(:to_ary)
32
+
33
+ existing_appenders_and_lvls = SemanticLogger.appenders.map { |app| [app, app.level] }
34
+ SemanticLogger.appenders.each { |app| app.level = :fatal }
35
+ original_levels_and_loggers = loggers.map { |log| [log, log.level] }
36
+ loggers.each { |log| log.level = level }
37
+
38
+ io = StringIO.new
39
+ appender = SemanticLogger.add_appender(io: io, level: level)
40
+ appender.formatter = formatter if formatter
41
+ begin
42
+ yield
43
+ ensure
44
+ SemanticLogger.flush
45
+ SemanticLogger.remove_appender(appender)
46
+ original_levels_and_loggers.each { |(log, lvl)| log.level = lvl }
47
+ existing_appenders_and_lvls.each { |(app, lvl)| app.level = lvl }
48
+ end
49
+ return io.string.lines
50
+ end
51
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "appydays/version"
4
+
5
+ RSpec::Matchers.define_negated_matcher(:exclude, :include)
6
+ RSpec::Matchers.define_negated_matcher(:not_include, :include)
7
+ RSpec::Matchers.define_negated_matcher(:not_change, :change)
8
+ RSpec::Matchers.define_negated_matcher(:not_be_nil, :be_nil)
9
+ RSpec::Matchers.define_negated_matcher(:not_be_empty, :be_empty)
10
+
11
+ module Appydays::SpecHelpers
12
+ # Zero out nsecs to t can be compared to one from the database.
13
+ module_function def trunc_time(t)
14
+ return t.change(nsec: t.usec * 1000)
15
+ end
16
+
17
+ #
18
+ # :section: Matchers
19
+ #
20
+
21
+ class HaveALineMatching
22
+ def initialize(regexp)
23
+ @regexp = regexp
24
+ end
25
+
26
+ def matches?(target)
27
+ @target = target
28
+ return @target.find do |obj|
29
+ obj.to_s.match(@regexp)
30
+ end
31
+ end
32
+
33
+ def failure_message
34
+ return "expected %p to have at least one line matching %p" % [@target, @regexp]
35
+ end
36
+
37
+ alias failure_message_for_should failure_message
38
+
39
+ def failure_message_when_negated
40
+ return "expected %p not to have any lines matching %p, but it has at least one" % [@target, @regexp]
41
+ end
42
+
43
+ alias failure_message_for_should_not failure_message_when_negated
44
+ end
45
+
46
+ ### RSpec matcher -- set up the expectation that the lefthand side
47
+ ### is Enumerable, and that at least one of the objects yielded
48
+ ### while iterating matches +regexp+ when converted to a String.
49
+ module_function def have_a_line_matching(regexp)
50
+ return HaveALineMatching.new(regexp)
51
+ end
52
+
53
+ module_function def have_length(x)
54
+ return RSpec::Matchers::BuiltIn::HaveAttributes.new(length: x)
55
+ end
56
+
57
+ # Matcher that will compare a string or time expected against a string or time actual,
58
+ # within a tolerance (default to 1 millisecond).
59
+ #
60
+ # expect(last_response).to have_json_body.that_includes(
61
+ # closes_at: match_time('2025-12-01T00:00:00.000+00:00').within(1.second))
62
+ #
63
+ RSpec::Matchers.define(:match_time) do |expected|
64
+ match do |actual|
65
+ @tolerance ||= 0.001
66
+ RSpec::Matchers::BuiltIn::BeWithin.new(@tolerance).of(self.time(expected)).matches?(self.time(actual))
67
+ end
68
+
69
+ failure_message do |actual|
70
+ "expected ids %s to be within %s of %s" % [self.time(actual), @tolerance, self.time(expected)]
71
+ end
72
+
73
+ chain :within do |tolerance|
74
+ @tolerance = tolerance
75
+ end
76
+
77
+ def time(s)
78
+ return Time.parse(s) if s.is_a?(String)
79
+ return s.to_time
80
+ end
81
+ end
82
+
83
+ # Matcher that will compare a string or Money expected against a string or Money actual.
84
+ #
85
+ # expect(order.total).to cost('$25')
86
+ #
87
+ RSpec::Matchers.define(:cost) do |expected|
88
+ match do |actual|
89
+ @base = RSpec::Matchers::BuiltIn::Eq.new(self.money(expected))
90
+ @base.matches?(self.money(actual))
91
+ end
92
+
93
+ failure_message do |_actual|
94
+ @base.failure_message
95
+ end
96
+
97
+ def money(s)
98
+ return Monetize.parse(s) if s.is_a?(String)
99
+ return s if s.is_a?(Money)
100
+ return Money.new(s) if s.is_a?(Integer)
101
+ return Money.new(s[:cents], s[:currency]) if s.respond_to?(:key?) && s.key?(:cents) && s.key?(:currency)
102
+ raise "#{s} type #{s.class.name} not convertable to Money (add support or use supported type)"
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydays
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Appydays::Configurable do
4
+ describe "configurable" do
5
+ it "raises if no block is given" do
6
+ expect do
7
+ Class.new do
8
+ include Appydays::Configurable
9
+ configurable(:hello)
10
+ end
11
+ end.to raise_error(LocalJumpError)
12
+ end
13
+
14
+ describe "setting" do
15
+ it "creates an attr accessor with the given name and default value" do
16
+ cls = Class.new do
17
+ include Appydays::Configurable
18
+ configurable(:hello) do
19
+ setting :knob, "zero"
20
+ end
21
+ end
22
+ expect(cls).to have_attributes(knob: "zero")
23
+ end
24
+
25
+ it "pulls the value from the environment" do
26
+ ENV["ENVTEST_KNOB"] = "one"
27
+ cls = Class.new do
28
+ include Appydays::Configurable
29
+ configurable(:envtest) do
30
+ setting :knob, "zero"
31
+ end
32
+ end
33
+ expect(cls).to have_attributes(knob: "one")
34
+ end
35
+
36
+ it "can use a custom environment key" do
37
+ ENV["OTHER_KNOB"] = "two"
38
+ cls = Class.new do
39
+ include Appydays::Configurable
40
+ configurable(:hello) do
41
+ setting :knob, "zero", key: "OTHER_KNOB"
42
+ end
43
+ end
44
+ expect(cls).to have_attributes(knob: "two")
45
+ end
46
+
47
+ it "can convert the value given the converter" do
48
+ ENV["CONVTEST_KNOB"] = "0"
49
+ cls = Class.new do
50
+ include Appydays::Configurable
51
+ configurable(:convtest) do
52
+ setting :knob, "", convert: ->(v) { v + v }
53
+ end
54
+ end
55
+ expect(cls).to have_attributes(knob: "00")
56
+ end
57
+
58
+ it "does not run the converter if the default is used" do
59
+ cls = Class.new do
60
+ include Appydays::Configurable
61
+ configurable(:hello) do
62
+ setting :knob, "0", convert: ->(v) { v + v }
63
+ end
64
+ end
65
+ expect(cls).to have_attributes(knob: "0")
66
+ end
67
+
68
+ it "can use a nil default" do
69
+ cls = Class.new do
70
+ include Appydays::Configurable
71
+ configurable(:hello) do
72
+ setting :knob, nil
73
+ end
74
+ end
75
+ expect(cls).to have_attributes(knob: nil)
76
+ end
77
+
78
+ it "converts strings to floats if the default is a float" do
79
+ ENV["FLOATTEST_KNOB"] = "3.2"
80
+ cls = Class.new do
81
+ include Appydays::Configurable
82
+ configurable(:floattest) do
83
+ setting :knob, 1.5
84
+ end
85
+ end
86
+ expect(cls).to have_attributes(knob: 3.2)
87
+ end
88
+
89
+ it "converts strings to integers if the default is an integer" do
90
+ ENV["INTTEST_KNOB"] = "5"
91
+ cls = Class.new do
92
+ include Appydays::Configurable
93
+ configurable(:inttest) do
94
+ setting :knob, 2
95
+ end
96
+ end
97
+ expect(cls).to have_attributes(knob: 5)
98
+ end
99
+
100
+ it "can coerce strings to booleans" do
101
+ ENV["BOOLTEST_KNOB"] = "TRue"
102
+ cls = Class.new do
103
+ include Appydays::Configurable
104
+ configurable(:booltest) do
105
+ setting :knob, false
106
+ end
107
+ end
108
+ expect(cls).to have_attributes(knob: true)
109
+ end
110
+
111
+ it "does not run the converter when using the accessor" do
112
+ cls = Class.new do
113
+ include Appydays::Configurable
114
+ configurable(:booltest) do
115
+ setting :knob, 5
116
+ end
117
+ end
118
+ cls.knob = "5"
119
+ expect(cls).to have_attributes(knob: "5")
120
+ end
121
+
122
+ it "coalesces an empty string to nil" do
123
+ cls = Class.new do
124
+ include Appydays::Configurable
125
+ configurable(:hello) do
126
+ setting :knob, ""
127
+ end
128
+ end
129
+ expect(cls).to have_attributes(knob: nil)
130
+ end
131
+
132
+ it "errors if the default value is not a supported type" do
133
+ expect do
134
+ Class.new do
135
+ include Appydays::Configurable
136
+ configurable(:hello) do
137
+ setting :knob, []
138
+ end
139
+ end
140
+ end.to raise_error(TypeError)
141
+ end
142
+
143
+ it "runs a side effect" do
144
+ side_effect = []
145
+ Class.new do
146
+ include Appydays::Configurable
147
+ configurable(:hello) do
148
+ setting :knob, "zero", side_effect: ->(s) { side_effect << s }
149
+ end
150
+ end
151
+ expect(side_effect).to contain_exactly("zero")
152
+ end
153
+ end
154
+
155
+ it "can reset settings" do
156
+ cls = Class.new do
157
+ include Appydays::Configurable
158
+ configurable(:hello) do
159
+ setting :knob, 1
160
+ end
161
+ end
162
+ cls.knob = 5
163
+ expect(cls).to have_attributes(knob: 5)
164
+ cls.reset_configuration
165
+ expect(cls).to have_attributes(knob: 1)
166
+ end
167
+
168
+ it "runs after_configure hooks after configuration" do
169
+ side_effect = []
170
+ Class.new do
171
+ include Appydays::Configurable
172
+ configurable(:hello) do
173
+ setting :knob, 1
174
+ after_configured do
175
+ side_effect << self.knob
176
+ end
177
+ end
178
+ end
179
+ expect(side_effect).to contain_exactly(1)
180
+ end
181
+
182
+ it "can run after_configured hooks explicitly" do
183
+ side_effect = []
184
+ cls = Class.new do
185
+ include Appydays::Configurable
186
+ configurable(:hello) do
187
+ setting :knob, 1
188
+ after_configured do
189
+ side_effect << self.knob
190
+ end
191
+ end
192
+ end
193
+ cls.run_after_configured_hooks
194
+ expect(side_effect).to contain_exactly(1, 1)
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Appydays::Dotenviable do
4
+ it "loads env files using RACK_ENV" do
5
+ expect(Dotenv).to receive(:load).with(".env.foo.local", ".env.foo", ".env")
6
+ described_class.load(env: {"RACK_ENV" => "foo"})
7
+ end
8
+
9
+ it "loads env files using the explicit env" do
10
+ expect(Dotenv).to receive(:load).with(".env.bar.local", ".env.bar", ".env")
11
+ described_class.load(rack_env: "bar")
12
+ end
13
+
14
+ it "loads env files with the given default env if no RACK_ENV is defined" do
15
+ expect(Dotenv).to receive(:load).with(".env.bar.local", ".env.bar", ".env")
16
+ described_class.load(default_rack_env: "bar", env: {})
17
+ end
18
+
19
+ it "loads env files with RACK_ENV rather than the default, if RACK_ENV is defined" do
20
+ expect(Dotenv).to receive(:load).with(".env.bar.local", ".env.bar", ".env")
21
+ described_class.load(default_rack_env: "foo", env: {"RACK_ENV" => "bar"})
22
+ end
23
+
24
+ it "reapplies the original port if one was not loaded" do
25
+ env = {"PORT" => "123"}
26
+ expect(Dotenv).to receive(:load)
27
+ described_class.load(env: env)
28
+ expect(env).to include("PORT" => "123")
29
+ end
30
+
31
+ it "does not reapply the original port if one was loaded" do
32
+ env = {"PORT" => "123"}
33
+ expect(Dotenv).to receive(:load) { env["PORT"] = "456" }
34
+ described_class.load(env: env)
35
+ expect(env).to include("PORT" => "456")
36
+ end
37
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "appydays/loggable/request_logger"
4
+
5
+ RSpec.describe Appydays::Loggable do
6
+ it "can set the default log level" do
7
+ expect(SemanticLogger).to receive(:default_level=).with("trace")
8
+ described_class.default_level = "trace"
9
+ end
10
+
11
+ it "can look up the logger for an object for a non-Loggable" do
12
+ cls = Class.new
13
+ cls_logger = described_class[cls]
14
+ inst_logger = described_class[cls.new]
15
+ expect(cls_logger).to be_a(SemanticLogger::Logger)
16
+ expect(inst_logger).to be_a(SemanticLogger::Logger)
17
+ expect(cls_logger.name).to eq(inst_logger.name)
18
+ end
19
+
20
+ it "can look up the logger for an object for a Loggable" do
21
+ cls = Class.new do
22
+ include Appydays::Loggable
23
+ end
24
+ cls_logger = described_class[cls]
25
+ inst = cls.new
26
+ inst_logger = described_class[inst]
27
+ expect(cls_logger).to be(inst_logger)
28
+ expect(cls.logger).to be(inst.logger)
29
+ end
30
+
31
+ it "adds logger methods" do
32
+ cls = Class.new do
33
+ include Appydays::Loggable
34
+ end
35
+ inst = cls.new
36
+ expect(cls.logger).to be_a(SemanticLogger::Logger)
37
+ expect(inst.logger).to be_a(SemanticLogger::Logger)
38
+ end
39
+
40
+ describe "custom formatting" do
41
+ it "combines :payload, :tags, and :named_tags into :context" do
42
+ logger1 = described_class["spec-helper-test"]
43
+
44
+ lines = capture_logs_from(logger1, formatter: :json) do
45
+ SemanticLogger.tagged("tag1", "tag2") do
46
+ SemanticLogger.named_tagged(nt1: 1, nt2: 2) do
47
+ logger1.error("hello", opt1: 1, opt2: 2)
48
+ end
49
+ end
50
+ end
51
+ j = JSON.parse(lines[0])
52
+ expect(j).to include("context")
53
+ expect(j["context"]).to eq(
54
+ "_tags" => ["tag1", "tag2"],
55
+ "nt1" => 1,
56
+ "nt2" => 2,
57
+ "opt1" => 1,
58
+ "opt2" => 2,
59
+ )
60
+ end
61
+ end
62
+
63
+ describe "spec helpers" do
64
+ logger1 = described_class["spec-helper-test"]
65
+
66
+ it "can capture log lines to a logger" do
67
+ lines = capture_logs_from(logger1) do
68
+ logger1.error("hello there")
69
+ end
70
+ expect(lines).to have_a_line_matching(/hello there/)
71
+ end
72
+
73
+ it "can capture log lines to multiple loggers" do
74
+ lines = capture_logs_from(logger1) do
75
+ logger1.error("hello there")
76
+ end
77
+ expect(lines).to have_a_line_matching(/hello there/)
78
+ end
79
+
80
+ it "can filter logs below a level" do
81
+ lines = capture_logs_from(logger1, level: :error) do
82
+ logger1.warn("hello there")
83
+ end
84
+ expect(lines).to be_empty
85
+ end
86
+
87
+ it "can specify the formatter" do
88
+ lines = capture_logs_from(logger1, formatter: :json) do
89
+ logger1.warn("hello there")
90
+ end
91
+ expect(lines).to have_a_line_matching(/"message":"hello there"/)
92
+
93
+ lines = capture_logs_from(logger1, formatter: :color) do
94
+ logger1.warn("hello there")
95
+ end
96
+ expect(lines).to have_a_line_matching(/-- hello there/)
97
+ end
98
+
99
+ it "sets and restores the level of all appenders" do
100
+ logger1.level = :info
101
+ other_appender = SemanticLogger.add_appender(io: StringIO.new, level: :trace)
102
+ capture_logs_from(logger1, level: :trace) do
103
+ expect(logger1.level).to eq(:trace)
104
+ expect(other_appender.level).to eq(:fatal)
105
+ end
106
+ expect(logger1.level).to eq(:info)
107
+ expect(other_appender.level).to eq(:trace)
108
+ SemanticLogger.remove_appender(other_appender)
109
+ end
110
+ end
111
+
112
+ describe Appydays::Loggable::RequestLogger do
113
+ def run_app(app, opts: {}, loggers: [], env: {}, cls: Appydays::Loggable::RequestLogger)
114
+ rl = cls.new(app, **opts.merge(reraise: false))
115
+ return capture_logs_from(loggers << rl.logger, formatter: :json) do
116
+ _, _, body = rl.call(env)
117
+ body&.close
118
+ end
119
+ end
120
+
121
+ it "logs info about the request" do
122
+ lines = run_app(proc { [200, {}, ""] })
123
+ expect(lines).to have_a_line_matching(/"message":"request_finished".*"response_status":200/)
124
+
125
+ lines = run_app(proc { [400, {}, ""] })
126
+ expect(lines).to have_a_line_matching(/"message":"request_finished".*"response_status":400/)
127
+ end
128
+
129
+ it "logs at 599 (or configured value) if something errors" do
130
+ lines = run_app(proc { raise "testing error" })
131
+ expect(lines).to have_a_line_matching(/"level":"error".*"response_status":599/)
132
+ expect(lines).to have_a_line_matching(/"message":"testing error"/)
133
+ end
134
+
135
+ it "logs slow queries at warn" do
136
+ lines = run_app(proc { [200, {}, ""] }, opts: {slow_request_seconds: 0})
137
+ expect(lines).to have_a_line_matching(/"level":"warn".*"response_status":200/)
138
+ end
139
+
140
+ it "logs errors at error" do
141
+ lines = run_app(proc { [504, {}, ""] }, opts: {slow_request_seconds: 0})
142
+ expect(lines).to have_a_line_matching(/"level":"error".*"response_status":504/)
143
+ end
144
+
145
+ it "adds tags around the execution of the request" do
146
+ logger = SemanticLogger["testlogger"]
147
+ lines = run_app(proc do
148
+ logger.info("check for tags")
149
+ [200, {}, ""]
150
+ end,
151
+ opts: {slow_request_seconds: 0}, loggers: [logger],)
152
+ expect(lines).to have_a_line_matching(/"message":"check for tags".*"request_method":/)
153
+ end
154
+
155
+ it "adds subclass tags" do
156
+ ReqLogger = Class.new(Appydays::Loggable::RequestLogger) do
157
+ def request_tags(env)
158
+ return {my_header_tag: env["HTTP_MY_HEADER"]}
159
+ end
160
+ end
161
+ lines = run_app(proc { [200, {}, ""] }, env: {"HTTP_MY_HEADER" => "myval"}, cls: ReqLogger)
162
+ expect(lines).to have_a_line_matching(/"my_header_tag":"myval"/)
163
+ end
164
+
165
+ it "adds a request id" do
166
+ lines = run_app(proc { [200, {}, ""] })
167
+ expect(lines).to have_a_line_matching(/"request_id":"[0-9a-z]{8}-/)
168
+ end
169
+
170
+ it "reads a trace id from headers" do
171
+ lines = run_app(proc { [200, {}, ""] }, env: {"HTTP_TRACE_ID" => "123xyz"})
172
+ expect(lines).to have_a_line_matching(/"trace_id":"123xyz"/)
173
+ end
174
+
175
+ it "sets the trace ID header if not set" do
176
+ env = {}
177
+ lines = run_app(proc do
178
+ expect(env).to(include("HTTP_TRACE_ID"))
179
+ [200, {}, ""]
180
+ end, env: env,)
181
+ expect(lines).to have_a_line_matching(/"trace_id":"[0-9a-z]{8}-/)
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # See https://github.com/eliotsykes/rspec-rails-examples/blob/master/spec/spec_helper.rb
4
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
5
+ #
6
+ require "appydays/dotenviable"
7
+ Appydays::Dotenviable.load(default_rack_env: "test")
8
+
9
+ require "rspec"
10
+ require "rspec/json_expectations"
11
+ require "appydays/loggable/spec_helpers"
12
+ require "appydays/spec_helpers"
13
+ require "appydays/configurable"
14
+
15
+ RSpec.configure do |config|
16
+ # config.full_backtrace = true
17
+
18
+ RSpec::Support::ObjectFormatter.default_instance.max_formatted_output_length = 600
19
+
20
+ config.expect_with :rspec do |expectations|
21
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
22
+ end
23
+
24
+ config.mock_with :rspec do |mocks|
25
+ mocks.verify_partial_doubles = true
26
+ end
27
+
28
+ config.order = :random
29
+ Kernel.srand config.seed
30
+
31
+ config.filter_run :focus
32
+ config.run_all_when_everything_filtered = true
33
+ config.disable_monkey_patching!
34
+ config.default_formatter = "doc" if config.files_to_run.one?
35
+
36
+ config.include(Appydays::Loggable::SpecHelpers)
37
+ config.include(Appydays::SpecHelpers)
38
+ end
metadata ADDED
@@ -0,0 +1,212 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: appydays
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: ruby
6
+ authors:
7
+ - Lithic Tech
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-03-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dotenv
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: semantic_logger
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rack
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec-core
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec-json_expectations
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop-performance
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rubocop-rake
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: rubocop-sequel
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ - !ruby/object:Gem::Dependency
154
+ name: sequel
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ description: 'appydays provides support for logging and handling environment variables
168
+
169
+ '
170
+ email:
171
+ executables: []
172
+ extensions: []
173
+ extra_rdoc_files: []
174
+ files:
175
+ - README.md
176
+ - lib/appydays.rb
177
+ - lib/appydays/configurable.rb
178
+ - lib/appydays/dotenviable.rb
179
+ - lib/appydays/loggable.rb
180
+ - lib/appydays/loggable/request_logger.rb
181
+ - lib/appydays/loggable/sequel_logger.rb
182
+ - lib/appydays/loggable/spec_helpers.rb
183
+ - lib/appydays/spec_helpers.rb
184
+ - lib/appydays/version.rb
185
+ - spec/appydays/configurable_spec.rb
186
+ - spec/appydays/dotenviable_spec.rb
187
+ - spec/appydays/loggable_spec.rb
188
+ - spec/spec_helper.rb
189
+ homepage:
190
+ licenses:
191
+ - MIT
192
+ metadata: {}
193
+ post_install_message:
194
+ rdoc_options: []
195
+ require_paths:
196
+ - lib
197
+ required_ruby_version: !ruby/object:Gem::Requirement
198
+ requirements:
199
+ - - ">="
200
+ - !ruby/object:Gem::Version
201
+ version: 2.7.2
202
+ required_rubygems_version: !ruby/object:Gem::Requirement
203
+ requirements:
204
+ - - ">="
205
+ - !ruby/object:Gem::Version
206
+ version: '0'
207
+ requirements: []
208
+ rubygems_version: 3.1.4
209
+ signing_key:
210
+ specification_version: 4
211
+ summary: Provides support for development best practices
212
+ test_files: []