kirei 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb100f4fb7ab34f19caf8d428e373ac0e94230b126037147108b4826ca08b0c5
4
- data.tar.gz: 00efe991393cecf639ffe87ac5407fcf232d068940f55da8cc1696c08bbf75e6
3
+ metadata.gz: 788221745d889864fe2ec7075cce289f6d44361dece7711fbe91ac24c16f693b
4
+ data.tar.gz: e03e3cf0c4c6db69bc0106107ccb002b8f472ba077ef457d88a84b92b188371f
5
5
  SHA512:
6
- metadata.gz: bee31a71691f31363a7ffc4855c2ef9886f1ce3e545aafa5409a8064e7de79ec78670172b6c7e61348fb5b21f61779e080818e25077b9c300ec4f612dda83e28
7
- data.tar.gz: 1d4511cbd01bc7f2eb5f03bd3032079232650b30226ca510dce48a07ee4fc7814e0e6e9ac25ba5df1cd14a11ee957a508d2cbededfbf29ee7806b1840d3455b9
6
+ metadata.gz: 30bef2a1458e1aeeebd774d577df1dc231b0cfa46fb7483b076b69ac05de1529527442599dca18bb278fec42e1a17888eee849feb1b3f81dd502fd92284dd879
7
+ data.tar.gz: 181df5735f94c222fc29e9bb3c18aa33340995925a324a18a0bb28e1001eaeaee24f8c7442cc5852adf31ae7e5e7a707743441e1c64a585e27039e7b50ba96da
data/README.md CHANGED
@@ -82,13 +82,13 @@ Delete keeps the original object intact. Returns `true` if the record was delete
82
82
  success = user.delete # => T::Boolean
83
83
 
84
84
  # or delete by any query:
85
- User.db.where('...').delete # => Integer, number of deleted records
85
+ User.query.where('...').delete # => Integer, number of deleted records
86
86
  ```
87
87
 
88
88
  To build more complex queries, Sequel can be used directly:
89
89
 
90
90
  ```ruby
91
- query = User.db.where({ name: 'John' })
91
+ query = User.query.where({ name: 'John' })
92
92
  query = query.where('...') # "query" is a 'Sequel::Dataset' that you can chain as you like
93
93
  query = query.limit(10)
94
94
 
@@ -154,14 +154,14 @@ Define routes anywhere in your app; by convention, they are defined in `config/r
154
154
  module Kirei::Routing
155
155
  Router.add_routes(
156
156
  [
157
- Router::Route.new(
158
- verb: Router::Verb::GET,
157
+ Route.new(
158
+ verb: Verb::GET,
159
159
  path: "/livez",
160
160
  controller: Controllers::Health,
161
161
  action: "livez",
162
162
  ),
163
- Router::Route.new(
164
- verb: Router::Verb::GET,
163
+ Route.new(
164
+ verb: Verb::GET,
165
165
  path: "/airports",
166
166
  controller: Controllers::Airports,
167
167
  action: "index",
@@ -182,7 +182,11 @@ module Controllers
182
182
 
183
183
  sig { returns(T.anything) }
184
184
  def index
185
- airports = Airport.all # T::Array[Airport]
185
+ search = T.let(params.fetch("q", nil), T.nilable(String))
186
+
187
+ airports = Kirei::Services::Runner.call("Airports::Filter") do
188
+ Airports::Filter.call(search) # T::Array[Airport]
189
+ end
186
190
 
187
191
  # or use a serializer
188
192
  data = Oj.dump(airports.map(&:serialize))
@@ -193,6 +197,35 @@ module Controllers
193
197
  end
194
198
  ```
195
199
 
200
+ Services can be PORO. You can wrap an execution in `Kirei::Services::Runner` which will emit a standardized logline and track its execution time.
201
+
202
+ ```ruby
203
+ module Airports
204
+ class Filter
205
+ extend T::Sig
206
+
207
+ sig do
208
+ params(
209
+ search: T.nilable(String),
210
+ ).returns(T::Array[Airport])
211
+ end
212
+ def self.call(search)
213
+ return Airport.all if search.nil?
214
+
215
+ #
216
+ # SELECT *
217
+ # FROM "airports"
218
+ # WHERE (("name" ILIKE 'xx%') OR ("id" ILIKE 'xx%'))
219
+ #
220
+ query = Airport.query.where(Sequel.ilike(:name, "#{search}%"))
221
+ query = query.or(Sequel.ilike(:id, "#{search}%"))
222
+
223
+ Airport.resolve(query)
224
+ end
225
+ end
226
+ end
227
+ ```
228
+
196
229
  ## Contributions
197
230
 
198
231
  We welcome contributions from the community. Before starting work on a major feature, please get in touch with us either via email or by opening an issue on GitHub. "Major feature" means anything that changes user-facing features or significant changes to the codebase itself.
data/kirei.gemspec CHANGED
@@ -46,6 +46,7 @@ Gem::Specification.new do |spec|
46
46
  # Utilities
47
47
  spec.add_dependency "oj", "~> 3.0"
48
48
  spec.add_dependency "sorbet-runtime", "~> 0.5"
49
+ spec.add_dependency "statsd-instrument", "~> 3.0"
49
50
  spec.add_dependency "tzinfo-data", "~> 1.0" # for containerized environments, e.g. on AWS ECS
50
51
  spec.add_dependency "zeitwerk", "~> 2.5"
51
52
 
@@ -16,7 +16,7 @@ module Cli
16
16
  Files::Routes.call(app_name)
17
17
  Files::SorbetConfig.call
18
18
 
19
- Kirei::Logger.logger.info(
19
+ Kirei::Logging::Logger.logger.info(
20
20
  "Kirei app '#{app_name}' scaffolded successfully!",
21
21
  )
22
22
  end
@@ -31,8 +31,15 @@ module Cli
31
31
  # Fourth: load all application code
32
32
  loader = Zeitwerk::Loader.new
33
33
  loader.tag = File.basename(__FILE__, ".rb")
34
- loader.push_dir("#{File.dirname(__FILE__)}/app")
35
- loader.push_dir("#{File.dirname(__FILE__)}/app/models") # make models a root namespace so we don't infer a `Models::` module
34
+ [
35
+ "/app",
36
+ "/app/models",
37
+ "/app/services",
38
+ ].each do |root_namespace|
39
+ # a root namespace skips the auto-infered module for this folder
40
+ # so we don't have to write e.g. `Models::` or `Services::`
41
+ loader.push_dir("\#{File.dirname(__FILE__)}\#{root_namespace}")
42
+ end
36
43
  loader.setup
37
44
 
38
45
  # Fifth: load configs
@@ -19,8 +19,8 @@ module Cli
19
19
  module Kirei::Routing
20
20
  Router.add_routes(
21
21
  [
22
- Router::Route.new(
23
- verb: Router::Verb::GET,
22
+ Route.new(
23
+ verb: Verb::GET,
24
24
  path: "/livez",
25
25
  controller: Controllers::Health,
26
26
  action: "livez",
@@ -13,7 +13,7 @@ module Cli
13
13
  app_name = app_name.split("_").map(&:capitalize).join if app_name.include?("_")
14
14
  NewApp::Execute.call(app_name: app_name)
15
15
  else
16
- Kirei::Logger.logger.info("Unknown command")
16
+ Kirei::Logging::Logger.logger.info("Unknown command")
17
17
  end
18
18
  end
19
19
  end
data/lib/kirei/app.rb CHANGED
@@ -2,6 +2,9 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Kirei
5
+ #
6
+ # This is the entrypoint into the application; it implements the Rack interface.
7
+ #
5
8
  class App < Routing::Base
6
9
  class << self
7
10
  extend T::Sig
data/lib/kirei/config.rb CHANGED
@@ -17,7 +17,10 @@ module Kirei
17
17
 
18
18
  prop :logger, ::Logger, factory: -> { ::Logger.new($stdout) }
19
19
  prop :log_transformer, T.nilable(T.proc.params(msg: T::Hash[Symbol, T.untyped]).returns(T::Array[String]))
20
- prop :log_default_metadata, T::Hash[Symbol, String], default: {}
20
+ prop :log_default_metadata, T::Hash[String, T.untyped], default: {}
21
+ prop :log_level, Kirei::Logging::Level, default: Kirei::Logging::Level::INFO
22
+
23
+ prop :metric_default_tags, T::Hash[String, T.untyped], default: {}
21
24
 
22
25
  # dup to allow the user to extend the existing list of sensitive keys
23
26
  prop :sensitive_keys, T::Array[Regexp], factory: -> { SENSITIVE_KEYS.dup }
@@ -0,0 +1,25 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Errors
6
+ #
7
+ # https://jsonapi.org/format/#errors
8
+ # Error objects MUST be returned as an array keyed by errors in the top level of a JSON:API document.
9
+ #
10
+ class JsonApiError < T::Struct
11
+ #
12
+ # An application-specific error code, expressed as a string value.
13
+ #
14
+ const :code, String
15
+
16
+ #
17
+ # A human-readable explanation specific to this occurrence of the problem.
18
+ # Like title, this field's value can be localized.
19
+ #
20
+ const :detail, T.nilable(String)
21
+
22
+ const :source, T.nilable(JsonApiErrorSource)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,12 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Errors
6
+ class JsonApiErrorSource < T::Struct
7
+ const :attribute, String
8
+ const :model, T.nilable(String)
9
+ const :id, T.nilable(String)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,33 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Logging
6
+ class Level < T::Enum
7
+ extend T::Sig
8
+
9
+ enums do
10
+ UNKNOWN = new(5) # An unknown message that should always be logged.
11
+ FATAL = new(4) # An unhandleable error that results in a program crash.
12
+ ERROR = new(3) # A handleable error condition.
13
+ WARN = new(2) # A warning.
14
+ INFO = new(1) # Generic (useful) information about system operation.
15
+ DEBUG = new(0) # Low-level information for developers.
16
+ end
17
+
18
+ sig { returns(String) }
19
+ def to_human
20
+ case self
21
+ when UNKNOWN then "UNKNOWN"
22
+ when FATAL then "FATAL"
23
+ when ERROR then "ERROR"
24
+ when WARN then "WARN"
25
+ when INFO then "INFO"
26
+ when DEBUG then "DEBUG"
27
+ else
28
+ T.absurd(self)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,198 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ # rubocop:disable Metrics
6
+
7
+ #
8
+ # Example Usage:
9
+ #
10
+ # Kirei::Logging::Logger.call(
11
+ # level: Kirei::Logging::Level::INFO,
12
+ # label: "Request started",
13
+ # meta: {
14
+ # key: "value",
15
+ # },
16
+ # )
17
+ #
18
+ # You can define a custom log transformer to transform the logline:
19
+ #
20
+ # Kirei::App.config.log_transformer = Proc.new { _1 }
21
+ #
22
+ # By default, "meta" is flattened, and sensitive values are masked using sane defaults that you
23
+ # can finetune via `Kirei::App.config.sensitive_keys`.
24
+ #
25
+ # You can also build on top of the provided log transformer:
26
+ #
27
+ # Kirei::App.config.log_transformer = Proc.new do |meta|
28
+ # flattened_meta = Kirei::Logging::Logger.flatten_hash_and_mask_sensitive_values(meta)
29
+ # # Do something with the flattened meta
30
+ # flattened_meta.map { _1.to_json }
31
+ # end
32
+ #
33
+ # NOTE:
34
+ # * The log transformer must return an array of strings to allow emitting multiple lines per log event.
35
+ # * Whenever possible, key names follow OpenTelemetry Semantic Conventions, https://opentelemetry.io/docs/concepts/semantic-conventions/
36
+ #
37
+ module Logging
38
+ class Logger
39
+ extend T::Sig
40
+
41
+ FILTERED = "[FILTERED]"
42
+
43
+ @instance = T.let(nil, T.nilable(Kirei::Logging::Logger))
44
+
45
+ sig { void }
46
+ def initialize
47
+ super
48
+ @queue = T.let(Thread::Queue.new, Thread::Queue)
49
+ @thread = T.let(start_logging_thread, Thread)
50
+ end
51
+
52
+ sig { returns(Kirei::Logging::Logger) }
53
+ def self.instance
54
+ @instance ||= new
55
+ end
56
+
57
+ sig { returns(::Logger) }
58
+ def self.logger
59
+ return @logger unless @logger.nil?
60
+
61
+ @logger = T.let(nil, T.nilable(::Logger))
62
+ @logger ||= ::Logger.new($stdout)
63
+
64
+ # we want the logline to be parseable to JSON
65
+ @logger.formatter = proc do |_severity, _datetime, _progname, msg|
66
+ "#{msg}\n"
67
+ end
68
+
69
+ @logger
70
+ end
71
+
72
+ sig do
73
+ params(
74
+ level: Logging::Level,
75
+ label: String,
76
+ meta: T::Hash[String, T.untyped],
77
+ ).void
78
+ end
79
+ def self.call(level:, label:, meta: {})
80
+ return if ENV["NO_LOGS"] == "true"
81
+ return if level.serialize < App.config.log_level.serialize
82
+
83
+ # must extract data from current thread before passing this down to the logging thread
84
+ meta["enduser.id"] ||= Thread.current[:enduser_id] # OpenTelemetry::SemanticConventions::Trace::ENDUSER_ID
85
+ meta["service.instance.id"] ||= Thread.current[:request_id] # OpenTelemetry::SemanticConventions::Resource::SERVICE_INSTANCE_ID
86
+
87
+ instance.call(level: level, label: label, meta: meta)
88
+ end
89
+
90
+ sig do
91
+ params(
92
+ level: Logging::Level,
93
+ label: String,
94
+ meta: T::Hash[String, T.untyped],
95
+ ).void
96
+ end
97
+ def call(level:, label:, meta: {})
98
+ Kirei::App.config.log_default_metadata.each_pair do |key, value|
99
+ meta[key] ||= value
100
+ end
101
+
102
+ meta["service.name"] ||= Kirei::App.config.app_name # OpenTelemetry::SemanticConventions::Resource::SERVICE_NAME
103
+ meta["service.version"] = Kirei::App.version # OpenTelemetry::SemanticConventions::Resource::SERVICE_VERSION
104
+ meta["timestamp"] ||= Time.now.utc.iso8601
105
+ meta["level"] ||= level.to_human
106
+ meta["label"] ||= label
107
+
108
+ @queue << meta
109
+ end
110
+
111
+ sig { returns(Thread) }
112
+ def start_logging_thread
113
+ Thread.new do
114
+ Kernel.loop do
115
+ log_data = T.let(@queue.pop, T::Hash[Symbol, T.untyped])
116
+ log_transformer = App.config.log_transformer
117
+
118
+ loglines = if log_transformer
119
+ log_transformer.call(log_data)
120
+ else
121
+ [Oj.dump(
122
+ Kirei::Logging::Logger.flatten_hash_and_mask_sensitive_values(log_data),
123
+ Kirei::OJ_OPTIONS,
124
+ )]
125
+ end
126
+
127
+ loglines.each do |logline|
128
+ logline = "\n#{logline}\n" if Kirei::App.environment == "development"
129
+ Kirei::Logging::Logger.logger.unknown(logline)
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ # rubocop:disable Naming/MethodParameterName
136
+ sig do
137
+ params(
138
+ k: String,
139
+ v: String,
140
+ ).returns(String)
141
+ end
142
+ def self.mask(k, v)
143
+ App.config.sensitive_keys.any? { k.match?(_1) } ? FILTERED : v
144
+ end
145
+ # rubocop:enable Naming/MethodParameterName
146
+
147
+ sig do
148
+ params(
149
+ hash: T::Hash[T.any(Symbol, String), T.untyped],
150
+ prefix: String,
151
+ ).returns(T::Hash[String, T.untyped])
152
+ end
153
+ def self.flatten_hash_and_mask_sensitive_values(hash, prefix = "")
154
+ result = T.let({}, T::Hash[String, T.untyped])
155
+ Kirei::Helpers.deep_stringify_keys!(hash)
156
+ hash = T.cast(hash, T::Hash[String, T.untyped])
157
+
158
+ hash.each do |key, value|
159
+ new_prefix = Kirei::Helpers.blank?(prefix) ? key : "#{prefix}.#{key}"
160
+
161
+ case value
162
+ when Hash
163
+ # Some libraries have a custom Hash class that inhert from Hash, but act differently, e.g. OmniAuth::AuthHash.
164
+ # This results in `transform_keys` being available but without any effect.
165
+ value = value.to_h if value.class != Hash
166
+ result.merge!(flatten_hash_and_mask_sensitive_values(value.transform_keys(&:to_s), new_prefix))
167
+ when Array
168
+ value.each_with_index do |element, index|
169
+ if element.is_a?(Hash) || element.is_a?(Array)
170
+ result.merge!(flatten_hash_and_mask_sensitive_values({ index => element }, new_prefix))
171
+ else
172
+ result["#{new_prefix}.#{index}"] = element.is_a?(String) ? mask(key, element) : element
173
+ end
174
+ end
175
+ when String then result[new_prefix] = mask(key, value)
176
+ when Numeric, FalseClass, TrueClass, NilClass then result[new_prefix] = value
177
+ else
178
+ if value.respond_to?(:serialize)
179
+ serialized_value = value.serialize
180
+ if serialized_value.is_a?(Hash)
181
+ result.merge!(
182
+ flatten_hash_and_mask_sensitive_values(serialized_value.transform_keys(&:to_s), new_prefix),
183
+ )
184
+ else
185
+ result[new_prefix] = serialized_value&.to_s
186
+ end
187
+ else
188
+ result[new_prefix] = value&.to_s
189
+ end
190
+ end
191
+ end
192
+
193
+ result
194
+ end
195
+ end
196
+ # rubocop:enable Metrics
197
+ end
198
+ end
@@ -0,0 +1,40 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Logging
6
+ class Metric
7
+ extend T::Sig
8
+
9
+ sig do
10
+ params(
11
+ metric_name: String,
12
+ value: Integer,
13
+ tags: T::Hash[String, T.untyped],
14
+ ).void
15
+ end
16
+ def self.call(metric_name, value = 1, tags: {})
17
+ return if ENV["NO_METRICS"] == "true"
18
+
19
+ inject_defaults(tags)
20
+
21
+ # Do not `compact_blank` tags, since one might want to track empty strings/"false"/NULLs.
22
+ # NOT having any tag doesn't tell the user if the tag was empty or not set at all.
23
+ StatsD.increment(metric_name, value, tags: tags)
24
+ end
25
+
26
+ sig { params(tags: T::Hash[String, T.untyped]).returns(T::Hash[String, T.untyped]) }
27
+ def self.inject_defaults(tags)
28
+ App.config.metric_default_tags.each_pair do |key, default_value|
29
+ tags[key] ||= default_value
30
+ end
31
+
32
+ tags["enduser.id"] ||= Thread.current[:enduser_id]
33
+ tags["service.name"] ||= Kirei::App.config.app_name # OpenTelemetry::SemanticConventions::Resource::SERVICE_NAME
34
+ tags["service.version"] = Kirei::App.version # OpenTelemetry::SemanticConventions::Resource::SERVICE_VERSION
35
+
36
+ tags
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,55 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # rubocop:disable Style/EmptyMethod
5
+
6
+ module Kirei
7
+ module Model
8
+ module BaseClassInterface
9
+ extend T::Sig
10
+ extend T::Helpers
11
+ interface!
12
+
13
+ sig { abstract.params(hash: T.untyped).returns(T.untyped) }
14
+ def find_by(hash); end
15
+
16
+ sig { abstract.params(hash: T.untyped).returns(T.untyped) }
17
+ def where(hash); end
18
+
19
+ sig { abstract.returns(T.untyped) }
20
+ def all; end
21
+
22
+ sig { abstract.params(hash: T.untyped).returns(T.untyped) }
23
+ def create(hash); end
24
+
25
+ sig { abstract.params(attributes: T.untyped).void }
26
+ def wrap_jsonb_non_primivitives!(attributes); end
27
+
28
+ sig { abstract.params(hash: T.untyped).returns(T.untyped) }
29
+ def resolve(hash); end
30
+
31
+ sig { abstract.params(hash: T.untyped).returns(T.untyped) }
32
+ def resolve_first(hash); end
33
+
34
+ sig { abstract.returns(T.untyped) }
35
+ def table_name; end
36
+
37
+ sig { abstract.returns(T.untyped) }
38
+ def query; end
39
+
40
+ sig { abstract.returns(T.untyped) }
41
+ def db; end
42
+
43
+ sig { abstract.returns(T.untyped) }
44
+ def human_id_length; end
45
+
46
+ sig { abstract.returns(T.untyped) }
47
+ def human_id_prefix; end
48
+
49
+ sig { abstract.returns(T.untyped) }
50
+ def generate_human_id; end
51
+ end
52
+ end
53
+ end
54
+
55
+ # rubocop:enable Style/EmptyMethod