kirei 0.2.1 → 0.4.0

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