appydays 0.1.2

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 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: []