kirei 0.2.1 → 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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +74 -25
  3. data/bin/kirei +1 -1
  4. data/kirei.gemspec +5 -3
  5. data/lib/cli/commands/new_app/base_directories.rb +1 -1
  6. data/lib/cli/commands/new_app/execute.rb +3 -3
  7. data/lib/cli/commands/new_app/files/app.rb +16 -3
  8. data/lib/cli/commands/new_app/files/config_ru.rb +1 -1
  9. data/lib/cli/commands/new_app/files/db_rake.rb +50 -2
  10. data/lib/cli/commands/new_app/files/irbrc.rb +1 -1
  11. data/lib/cli/commands/new_app/files/rakefile.rb +1 -1
  12. data/lib/cli/commands/new_app/files/routes.rb +49 -12
  13. data/lib/cli/commands/new_app/files/sorbet_config.rb +1 -1
  14. data/lib/cli/commands/start.rb +1 -1
  15. data/lib/kirei/app.rb +76 -56
  16. data/lib/kirei/config.rb +4 -1
  17. data/lib/kirei/controller.rb +44 -0
  18. data/lib/kirei/errors/json_api_error.rb +25 -0
  19. data/lib/kirei/errors/json_api_error_source.rb +12 -0
  20. data/lib/kirei/logging/level.rb +33 -0
  21. data/lib/kirei/logging/logger.rb +198 -0
  22. data/lib/kirei/logging/metric.rb +40 -0
  23. data/lib/kirei/model/base_class_interface.rb +55 -0
  24. data/lib/kirei/{base_model.rb → model/class_methods.rb} +42 -108
  25. data/lib/kirei/model/human_id_generator.rb +40 -0
  26. data/lib/kirei/model.rb +52 -0
  27. data/lib/kirei/routing/base.rb +187 -0
  28. data/lib/kirei/routing/nilable_hooks_type.rb +10 -0
  29. data/lib/kirei/{middleware.rb → routing/rack_env_type.rb} +1 -10
  30. data/lib/kirei/routing/rack_response_type.rb +15 -0
  31. data/lib/kirei/routing/route.rb +13 -0
  32. data/lib/kirei/routing/router.rb +56 -0
  33. data/lib/kirei/routing/verb.rb +37 -0
  34. data/lib/kirei/services/result.rb +53 -0
  35. data/lib/kirei/services/runner.rb +47 -0
  36. data/lib/kirei/version.rb +1 -1
  37. data/lib/kirei.rb +31 -3
  38. data/sorbet/rbi/shims/base_model.rbi +1 -1
  39. data/sorbet/rbi/shims/ruby.rbi +15 -0
  40. metadata +55 -14
  41. data/lib/boot.rb +0 -23
  42. data/lib/kirei/app_base.rb +0 -72
  43. data/lib/kirei/base_controller.rb +0 -16
  44. data/lib/kirei/logger.rb +0 -196
  45. data/lib/kirei/router.rb +0 -61
@@ -0,0 +1,44 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ class Controller < Routing::Base
6
+ class << self
7
+ extend T::Sig
8
+
9
+ sig { returns(Routing::NilableHooksType) }
10
+ attr_reader :before_hooks, :after_hooks
11
+ end
12
+
13
+ extend T::Sig
14
+
15
+ #
16
+ # Statements to be executed before every action.
17
+ #
18
+ # In development mode, Rack Reloader might reload this file causing
19
+ # the before hooks to be executed multiple times.
20
+ #
21
+ sig do
22
+ params(
23
+ block: T.nilable(T.proc.void),
24
+ ).void
25
+ end
26
+ def self.before(&block)
27
+ @before_hooks ||= T.let(Set.new, Routing::NilableHooksType)
28
+ @before_hooks.add(block) if block
29
+ end
30
+
31
+ #
32
+ # Statements to be executed after every action.
33
+ #
34
+ sig do
35
+ params(
36
+ block: T.nilable(T.proc.void),
37
+ ).void
38
+ end
39
+ def self.after(&block)
40
+ @after_hooks ||= T.let(Set.new, Routing::NilableHooksType)
41
+ @after_hooks.add(block) if block
42
+ end
43
+ end
44
+ end
@@ -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
@@ -2,120 +2,36 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Kirei
5
- module BaseModel
6
- extend T::Sig
7
- extend T::Helpers
8
-
9
- sig { returns(BaseClassInterface) }
10
- def class; super; end # rubocop:disable all
11
-
12
- # An update keeps the original object intact, and returns a new object with the updated values.
13
- sig do
14
- params(
15
- hash: T::Hash[Symbol, T.untyped],
16
- ).returns(T.self_type)
17
- end
18
- def update(hash)
19
- hash[:updated_at] = Time.now.utc if respond_to?(:updated_at) && hash[:updated_at].nil?
20
- self.class.wrap_jsonb_non_primivitives!(hash)
21
- self.class.db.where({ id: id }).update(hash)
22
- self.class.find_by({ id: id })
23
- end
24
-
25
- # Delete keeps the original object intact. Returns true if the record was deleted.
26
- # Calling delete multiple times will return false after the first (successful) call.
27
- sig { returns(T::Boolean) }
28
- def delete
29
- count = self.class.db.where({ id: id }).delete
30
- count == 1
31
- end
32
-
33
- # warning: this is not concurrency-safe
34
- # save keeps the original object intact, and returns a new object with the updated values.
35
- sig { returns(T.self_type) }
36
- def save
37
- previous_record = self.class.find_by({ id: id })
38
-
39
- hash = serialize
40
- Helpers.deep_symbolize_keys!(hash)
41
- hash = T.cast(hash, T::Hash[Symbol, T.untyped])
42
-
43
- if previous_record.nil?
44
- self.class.create(hash)
45
- else
46
- update(hash)
47
- end
48
- end
49
-
50
- module BaseClassInterface
51
- extend T::Sig
52
- extend T::Helpers
53
- interface!
54
-
55
- sig { abstract.params(hash: T.untyped).returns(T.untyped) }
56
- def find_by(hash)
57
- end
58
-
59
- sig { abstract.params(hash: T.untyped).returns(T.untyped) }
60
- def where(hash)
61
- end
62
-
63
- sig { abstract.returns(T.untyped) }
64
- def all
65
- end
66
-
67
- sig { abstract.params(hash: T.untyped).returns(T.untyped) }
68
- def create(hash)
69
- end
70
-
71
- sig { abstract.params(attributes: T.untyped).void }
72
- def wrap_jsonb_non_primivitives!(attributes)
73
- end
74
-
75
- sig { abstract.params(hash: T.untyped).returns(T.untyped) }
76
- def resolve(hash)
77
- end
78
-
79
- sig { abstract.params(hash: T.untyped).returns(T.untyped) }
80
- def resolve_first(hash)
81
- end
82
-
83
- sig { abstract.returns(T.untyped) }
84
- def table_name
85
- end
86
-
87
- sig { abstract.returns(T.untyped) }
88
- def db
89
- end
90
- end
91
-
5
+ module Model
92
6
  module ClassMethods
93
7
  extend T::Sig
94
8
  extend T::Generic
95
9
 
96
10
  # the attached class is the class that extends this module
97
- # e.g. "User"
98
- # extend T::Generic
99
- # has_attached_class!
11
+ # e.g. "User", "Airport", ..
100
12
  has_attached_class!
101
13
 
102
- include BaseClassInterface
14
+ include Kirei::Model::BaseClassInterface
103
15
 
104
16
  # defaults to a pluralized, underscored version of the class name
105
17
  sig { override.returns(String) }
106
18
  def table_name
107
- @table_name ||= T.let(
108
- begin
109
- table_name_ = Kirei::Helpers.underscore(T.must(name.split("::").last))
110
- "#{table_name_}s"
111
- end,
112
- T.nilable(String),
113
- )
19
+ @table_name ||= T.let("#{model_name}s", T.nilable(String))
20
+ end
21
+
22
+ sig { returns(String) }
23
+ def model_name
24
+ Kirei::Helpers.underscore(T.must(name.split("::").last))
114
25
  end
115
26
 
116
27
  sig { override.returns(Sequel::Dataset) }
28
+ def query
29
+ db[table_name.to_sym]
30
+ end
31
+
32
+ sig { override.returns(Sequel::Database) }
117
33
  def db
118
- AppBase.raw_db_connection[table_name.to_sym]
34
+ App.raw_db_connection
119
35
  end
120
36
 
121
37
  sig do
@@ -124,12 +40,12 @@ module Kirei
124
40
  ).returns(T::Array[T.attached_class])
125
41
  end
126
42
  def where(hash)
127
- resolve(db.where(hash))
43
+ resolve(query.where(hash))
128
44
  end
129
45
 
130
46
  sig { override.returns(T::Array[T.attached_class]) }
131
47
  def all
132
- resolve(db.all)
48
+ resolve(query.all)
133
49
  end
134
50
 
135
51
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
@@ -156,7 +72,7 @@ module Kirei
156
72
  all_attributes["updated_at"] = Time.now.utc
157
73
  end
158
74
 
159
- pkey = T.let(db.insert(all_attributes), String)
75
+ pkey = T.let(query.insert(all_attributes), String)
160
76
 
161
77
  T.must(find_by({ id: pkey }))
162
78
  end
@@ -166,7 +82,7 @@ module Kirei
166
82
  def wrap_jsonb_non_primivitives!(attributes)
167
83
  # setting `@raw_db_connection.wrap_json_primitives = true`
168
84
  # only works on JSON primitives, but not on blank hashes/arrays
169
- return unless AppBase.config.db_extensions.include?(:pg_json)
85
+ return unless App.config.db_extensions.include?(:pg_json)
170
86
 
171
87
  attributes.each_pair do |key, value|
172
88
  next unless value.is_a?(Hash) || value.is_a?(Array)
@@ -181,7 +97,7 @@ module Kirei
181
97
  ).returns(T.nilable(T.attached_class))
182
98
  end
183
99
  def find_by(hash)
184
- resolve_first(db.where(hash))
100
+ resolve_first(query.where(hash))
185
101
  end
186
102
 
187
103
  # Extra or unknown properties present in the Hash do not raise exceptions at
@@ -196,7 +112,7 @@ module Kirei
196
112
  ).returns(T::Array[T.attached_class])
197
113
  end
198
114
  def resolve(query, strict = nil)
199
- strict_loading = strict.nil? ? AppBase.config.db_strict_type_resolving : strict
115
+ strict_loading = strict.nil? ? App.config.db_strict_type_resolving : strict
200
116
 
201
117
  query.map do |row|
202
118
  row = T.cast(row, T::Hash[Symbol, T.untyped])
@@ -212,12 +128,30 @@ module Kirei
212
128
  ).returns(T.nilable(T.attached_class))
213
129
  end
214
130
  def resolve_first(query, strict = nil)
215
- strict_loading = strict.nil? ? AppBase.config.db_strict_type_resolving : strict
131
+ strict_loading = strict.nil? ? App.config.db_strict_type_resolving : strict
216
132
 
217
133
  resolve(query.limit(1), strict_loading).first
218
134
  end
219
- end
220
135
 
221
- mixes_in_class_methods(ClassMethods)
136
+ # defaults to 6
137
+ sig { override.returns(Integer) }
138
+ def human_id_length = 6
139
+
140
+ # defaults to "model_name" (table_name without the trailing "s")
141
+ sig { override.returns(String) }
142
+ def human_id_prefix = model_name
143
+
144
+ #
145
+ # Generates a human-readable ID for the record.
146
+ # The ID is prefixed with the table name and an underscore.
147
+ #
148
+ sig { override.returns(String) }
149
+ def generate_human_id
150
+ Kirei::Model::HumanIdGenerator.call(
151
+ length: human_id_length,
152
+ prefix: human_id_prefix,
153
+ )
154
+ end
155
+ end
222
156
  end
223
157
  end
@@ -0,0 +1,40 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Model
6
+ class HumanIdGenerator
7
+ extend T::Sig
8
+
9
+ # Removed ambiguous characters 0, 1, O, I, l, 5, S
10
+ ALLOWED_CHARS = "2346789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrtuvwxyz"
11
+ private_constant :ALLOWED_CHARS
12
+
13
+ ALLOWED_CHARS_COUNT = T.let(ALLOWED_CHARS.size, Integer)
14
+ private_constant :ALLOWED_CHARS_COUNT
15
+
16
+ sig do
17
+ params(
18
+ length: Integer,
19
+ prefix: String,
20
+ ).returns(String)
21
+ end
22
+ def self.call(length:, prefix:)
23
+ "#{prefix}_#{random_id(length)}"
24
+ end
25
+
26
+ sig { params(key_length: Integer).returns(String) }
27
+ private_class_method def self.random_id(key_length)
28
+ random_chars = Array.new(key_length)
29
+
30
+ key_length.times do |idx|
31
+ random_char_idx = SecureRandom.random_number(ALLOWED_CHARS_COUNT)
32
+ random_char = T.must(ALLOWED_CHARS[random_char_idx])
33
+ random_chars[idx] = random_char
34
+ end
35
+
36
+ random_chars.join
37
+ end
38
+ end
39
+ end
40
+ end