kirei 0.3.0 → 0.4.0

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