kirei 0.6.3 → 0.7.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: 6dec07a03cbf5ed8ceb535f60fef750df94039a2bd3880f219ccccefa4335f60
4
- data.tar.gz: f2a2796a4bcbe2aa6b8a526887c33ee22095c0afedf95e7e467b01b1e033b0ae
3
+ metadata.gz: df4eacd42ab9ef8cc4b3474e4c58b5ccdc0dc7c7b3631fdf4640a5ea38febc3b
4
+ data.tar.gz: 3203e23caa1ffa4cc1352b3d87bc30df0639c4434cfa26ae8b5cb3133a8586d7
5
5
  SHA512:
6
- metadata.gz: 1eab2d1497a5592d5a7fe55d669d94a55aeecbc44744bf46b1d5349658916584d6eace11032ef723f9d034bcac834d41acf8acc90f9d87922f38802bf1793567
7
- data.tar.gz: dc84e7e1bb9125909c81e40e66fc6e687abe8b1de928423d48b5a26a6c4ea23fd67e02953708bea25fbe448e5ce952f294ec54a6c8b74477f3dd269f891195ce
6
+ metadata.gz: 9bffc31e1489abec7e249e6f9328f0cd1337ee2a007bab192583de9b2d98c8bf2816ad743991f8c1ef073235da8fb072804a0a9d1af0248b28b3cd520bbfb8fe
7
+ data.tar.gz: e7ecfe6be3594b7ea70d12646d38a1b4e11e824453c63adfda37d96877791ca30fe2748098336cab13f25b774be92700065bbafeee84e648679242c2c1d47ad7
data/README.md CHANGED
@@ -55,6 +55,10 @@ Find a test app in the [spec/test_app](spec/test_app) directory. It is a fully f
55
55
 
56
56
  All models must inherit from `T::Struct` and include `Kirei::Model`. They must implement `id` which must hold the primary key of the table. The primary key must be named `id` and be of type `T.any(String, Integer)`.
57
57
 
58
+ Kirei models are immutable by convention - all properties are defined using `const` and updating a record returns a new instance rather than mutating the original. This immutability, combined with strict typing, makes them naturally suitable for both traditional data-centric applications and domain-driven design approaches.
59
+
60
+ In a domain-driven design, `Kirei::Model` serves as the persistence layer, while domain concepts are expressed through `Entity` and `ValueObject`. The domain layer might combine or transform data from multiple models to match the domain's understanding, keeping the internal data structure separate from the public interface.
61
+
58
62
  ```ruby
59
63
  class User < T::Struct
60
64
  extend T::Sig
@@ -99,6 +103,61 @@ first_user = User.resolve_first(query) # T.nilable(User)
99
103
  first_user = User.from_hash(query.first.stringify_keys)
100
104
  ```
101
105
 
106
+ #### Domain Objects
107
+
108
+ Kirei provides support for Domain-Driven Design patterns through `Kirei::Domain::Entity` and `Kirei::Domain::ValueObject`. Here's how to use them:
109
+
110
+ ```ruby
111
+ # An Entity is identified by its ID
112
+ class Flight < T::Struct
113
+ include Kirei::Domain::Entity
114
+
115
+ const :id, Integer
116
+
117
+ const :flight_number, String
118
+ const :departure_airport_id, Integer
119
+ const :arrival_airport_id, Integer
120
+ const :scheduled_departure_at, Time
121
+ const :status, String
122
+
123
+ sig { returns(T::Boolean) }
124
+ def can_board?
125
+ Time.now.utc <= scheduled_departure_at && status == 'on_time'
126
+ end
127
+ end
128
+
129
+ # A Value Object is identified by its attributes
130
+ # I.e. two Coordinates with the same lat/long will be equal
131
+ # regardless of object identity
132
+ class Coordinates < T::Struct
133
+ include Kirei::Domain::ValueObject
134
+
135
+ const :latitude, Float
136
+ const :longitude, Float
137
+
138
+ sig { returns(String) }
139
+ def to_s
140
+ "#{latitude},#{longitude}"
141
+ end
142
+
143
+ sig { params(other_coords: Coordinates).returns(Float) }
144
+ def distance_to(other_coords)
145
+ # Implement e.g. Haversine here.
146
+ end
147
+ end
148
+
149
+ # Usage example
150
+ coords = Coordinates.new(latitude: 37.6188, longitude: -122.3750)
151
+ coords2 = Coordinates.new(latitude: 37.6188, longitude: -122.3750)
152
+
153
+ coords == coords2 # true
154
+
155
+ flight = Flight.new(id: 123, flight_number: 'UA123', status: 'on_time', scheduled_departure_at: Time.now.utc)
156
+ flight2 = Flight.new(id: 123, flight_number: 'UA123', status: 'delayed', scheduled_departure_at: Time.now.utc)
157
+
158
+ flight == flight2 # true - same entity even with different status
159
+ ```
160
+
102
161
  #### Database Migrations
103
162
 
104
163
  Read the [Sequel Migrations](https://github.com/jeremyevans/sequel/blob/5.78.0/doc/schema_modification.rdoc) documentation for detailed information.
@@ -226,6 +285,31 @@ module Airports
226
285
  end
227
286
  ```
228
287
 
288
+ ### Goes well with these gems
289
+
290
+ * [pagy](https://github.com/ddnexus/pagy) for pagination
291
+ * [argon2](https://github.com/technion/ruby-argon2) for password hashing
292
+ * [rack-session](https://github.com/rack/rack-session) for session management
293
+
294
+ ### Middlewares
295
+
296
+ If you place custom middlewares under `config/middleware/*.rb`, they will be automatically loaded, see [app.rb](spec/test_app/app.rb) "load configs".
297
+
298
+ However, you still need to `use` them in the `config.ru` file, see the generated [config.ru](spec/test_app/config.ru) file, this gives you full control over the order in which middlewares are executed:
299
+
300
+ ```ruby
301
+ # Load middlewares here
302
+ use(Rack::Reloader, 0) if TestApp.environment == "development"
303
+
304
+ # add more custom middlewares here, e.g.
305
+ use(Middleware::Example)
306
+
307
+ # Launch the app
308
+ run(TestApp.new)
309
+ ```
310
+
311
+ Middleware provided by a gem like [rack-session](https://github.com/rack/rack-session) must be added to the `config.ru` file as well if you want to use it.
312
+
229
313
  ## Contributions
230
314
 
231
315
  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
@@ -17,7 +17,7 @@ Gem::Specification.new do |spec|
17
17
  spec.description = <<~TXT
18
18
  Kirei is a Ruby micro/REST-framework for building scalable and performant microservices.
19
19
  It is built from the ground up to be clean and easy to use.
20
- It is a Rack app, and uses Sorbet for typing, Sequel as an ORM, Zeitwerk for autoloading, and Puma as a web server.
20
+ It is a Rack app, and uses Sorbet for typing, Sequel as an ORM, and Zeitwerk for autoloading.
21
21
  It strives to have zero magic and to be as explicit as possible.
22
22
  TXT
23
23
  spec.homepage = "https://github.com/swiknaba/kirei"
@@ -44,6 +44,7 @@ Gem::Specification.new do |spec|
44
44
  spec.require_paths = ["lib"]
45
45
 
46
46
  # Utilities
47
+ spec.add_dependency "logger", "~> 1.5" # for Ruby 3.5+
47
48
  spec.add_dependency "oj", "~> 3.0"
48
49
  spec.add_dependency "sorbet-runtime", "~> 0.5"
49
50
  spec.add_dependency "statsd-instrument", "~> 3.0"
@@ -43,7 +43,9 @@ module Cli
43
43
  loader.setup
44
44
 
45
45
  # Fifth: load configs
46
- Dir[File.join(__dir__, "config", "*.rb")].each { require(_1) }
46
+ Dir[File.join(__dir__, "config", "**", "*.rb")].each do |cnf|
47
+ require(cnf) unless cnf.split("/").include?("initializers")
48
+ end
47
49
 
48
50
  class #{app_name} < Kirei::App
49
51
  # Kirei configuration
@@ -166,33 +166,51 @@ module Cli
166
166
  db = #{app_name}.raw_db_connection
167
167
  model_file_name = args[:model_file_name]&.to_s
168
168
 
169
- models_dir = #{app_name}.root
169
+ app_root_dir = TestApp.root
170
+ app_dir = File.join(TestApp.root, "app")
170
171
 
171
- Dir.glob("app/models/**/*.rb").each do |model_file|
172
+ Dir.glob("app/**/*.rb").each do |model_file|
172
173
  next if !model_file_name.nil? && model_file == model_file_name
173
174
 
174
- model_path = File.expand_path(model_file, models_dir)
175
- modules = model_file.gsub("app/models/", "").gsub(".rb", "").split("/").map { |mod| Zeitwerk::Inflector.new.camelize(mod, model_path) }
176
- const_name = modules.join("::")
177
- model_klass = Object.const_get(const_name)
178
- next unless model_klass.ancestors.include?(Kirei::Model)
175
+ model_path = File.expand_path(model_file, app_root_dir)
176
+ loader = Zeitwerk::Registry.loaders.find { |l| l.tag == "app" }
177
+
178
+ full_path = File.expand_path(model_file, app_root_dir)
179
+ klass_constant_name = loader.inflector.camelize(File.basename(model_file, ".rb"), full_path)
180
+
181
+ #
182
+ # root namespaces in Zeitwerk are flattend, e.g. if "app/models" is a root namespace
183
+ # then a file "app/models/airport.rb" is loaded as "::Airport".
184
+ # if it weren't a root namespace, it would be "::Models::Airport".
185
+ #
186
+ root_dir_namespaces = loader.dirs.filter_map { |dir| dir == app_dir ? nil : Pathname.new(dir).relative_path_from(Pathname.new(app_dir)).to_s }
187
+ relative_path = Pathname.new(full_path).relative_path_from(Pathname.new(app_dir)).to_s
188
+ root_dir_of_model = root_dir_namespaces.find { |root_dir| relative_path.start_with?(root_dir) }
189
+ relative_path.sub!("\#{root_dir_of_model}/", "") unless root_dir_of_model.nil? || root_dir_of_model.empty?
190
+
191
+ namespace_parts = relative_path.split("/")
192
+ namespace_parts.pop
193
+ namespace_parts.map! { |part| loader.inflector.camelize(part, full_path) }
194
+
195
+ constant_name = "\#{namespace_parts.join('::')}::\#{klass_constant_name}"
196
+
197
+ model_klass = Object.const_get(constant_name)
198
+ next unless model_klass.respond_to?(:table_name)
179
199
 
180
200
  table_name = model_klass.table_name
181
201
  schema = db.schema(table_name)
182
202
 
183
203
  schema_comments = format_schema_comments(table_name, schema)
204
+ file_content = File.read(model_path)
184
205
 
185
- file_contents = File.read(model_path)
186
-
187
- # Remove existing schema info comments if present
188
- updated_contents = file_contents.sub(/# == Schema Info\\n(.*?)(\\n#\\n)?\\n(?=\\s*(?:class|module))/m, "")
206
+ file_content_without_schema_info = file_content.sub(/# == Schema Info\\n(.*?)(\\n#\\n)?\\n(?=\\s*(?:class|module))/m, "")
189
207
 
190
208
  # Insert the new schema comments before the module/class definition
191
- first_const = modules.first
192
- first_module_or_class = modules.count == 1 ? "class #{first_const}" : "module #{first_const}"
193
- modified_contents = updated_contents.sub(/(A|\n)(#{first_module_or_class})/m, "\\1#{schema_comments}\n\n\\2")
209
+ first_module = namespace_parts.first
210
+ first_module_or_class = first_module.nil? ? "class \#{klass_constant_name}" : "module \#{first_module}"
211
+ modified_content = file_content_without_schema_info.sub(/(A|\\n)(\#{first_module_or_class})/m, "\\\\1\#{schema_comments}\\n\\n\\\\2")
194
212
 
195
- File.write(model_path, modified_contents)
213
+ File.write(model_path, modified_content)
196
214
  end
197
215
  end
198
216
  end
@@ -215,7 +233,7 @@ module Cli
215
233
  type ||= info[:db_type]
216
234
  null = info[:allow_null] ? 'null' : 'not null'
217
235
  primary_key = info[:primary_key] ? ', primary key' : ''
218
- lines << "# \#{name.to_s.ljust(20)}:\#{type} \#{null}\#{primary_key}"
236
+ lines << "# \#{name.to_s.ljust(20)}:\#{type.to_s.ljust(20)}\#{null}\#{primary_key}"
219
237
  end
220
238
  lines.join("\\n") + "\\n#"
221
239
  end
@@ -12,6 +12,10 @@ module Cli
12
12
  app_name = app_name.gsub(/[-\s]/, "_")
13
13
  app_name = app_name.split("_").map(&:capitalize).join if app_name.include?("_")
14
14
  NewApp::Execute.call(app_name: app_name)
15
+ when "test"
16
+ # for internal testing
17
+ app_name = args[1] || "TestApp"
18
+ # test single services here
15
19
  else
16
20
  Kirei::Logging::Logger.logger.info("Unknown command")
17
21
  end
data/lib/kirei/config.rb CHANGED
@@ -30,7 +30,7 @@ module Kirei
30
30
  # must use "pg_json" to parse jsonb columns to hashes
31
31
  #
32
32
  # Source: https://github.com/jeremyevans/sequel/blob/5.75.0/lib/sequel/extensions/pg_json.rb
33
- prop :db_extensions, T::Array[Symbol], default: %i[pg_json pg_array]
33
+ prop :db_extensions, T::Array[Symbol], default: %i[pg_json pg_array] # add "fiber_concurrency" by default, too?
34
34
  prop :db_url, T.nilable(String)
35
35
  # Extra or unknown properties present in the Hash do not raise exceptions at runtime
36
36
  # unless the optional strict argument to from_hash is passed
@@ -40,5 +40,38 @@ module Kirei
40
40
  @after_hooks ||= T.let(Set.new, Routing::NilableHooksType)
41
41
  @after_hooks.add(block) if block
42
42
  end
43
+
44
+ sig { returns(String) }
45
+ def req_host
46
+ env.fetch("HTTP_HOST")
47
+ end
48
+
49
+ sig { returns(String) }
50
+ def req_domain
51
+ T.must(req_host.split(":").first).split(".").last(2).join(".")
52
+ end
53
+
54
+ sig { returns(T.nilable(String)) }
55
+ def req_subdomain
56
+ parts = T.must(req_host.split(":").first).split(".")
57
+ return if parts.size <= 2
58
+
59
+ T.must(parts[0..-3]).join(".")
60
+ end
61
+
62
+ sig { returns(Integer) }
63
+ def req_port
64
+ env.fetch("SERVER_PORT")&.to_i
65
+ end
66
+
67
+ sig { returns(T::Boolean) }
68
+ def req_ssl?
69
+ env.fetch("HTTPS", env.fetch("rack.url_scheme", "http")) == "https"
70
+ end
71
+
72
+ sig { returns(T::Hash[String, T.untyped]) }
73
+ private def env
74
+ T.cast(@router.current_env, T::Hash[String, T.untyped])
75
+ end
43
76
  end
44
77
  end
@@ -0,0 +1,22 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Domain
6
+ module Entity
7
+ extend T::Sig
8
+ extend T::Helpers
9
+
10
+ sig { returns(T.class_of(T::Struct)) }
11
+ def class; super; end # rubocop:disable all
12
+
13
+ sig { params(other: T.nilable(Kirei::Domain::Entity)).returns(T::Boolean) }
14
+ def ==(other)
15
+ return false unless other.is_a?(Kirei::Domain::Entity)
16
+ return false unless instance_of?(other.class)
17
+
18
+ id == other.id
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,41 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Domain
6
+ module ValueObject
7
+ extend T::Sig
8
+ extend T::Helpers
9
+
10
+ sig { returns(T.class_of(T::Struct)) }
11
+ def class; super; end # rubocop:disable all
12
+
13
+ sig { params(other: T.untyped).returns(T::Boolean) }
14
+ def ==(other)
15
+ return false unless instance_of?(other.class)
16
+
17
+ instance_variables.all? do |var|
18
+ instance_variable_get(var) == other.instance_variable_get(var)
19
+ end
20
+ end
21
+
22
+ sig do
23
+ params(
24
+ other: T.untyped,
25
+ array_mode: Kirei::Services::ArrayComparison::Mode,
26
+ ).returns(T::Boolean)
27
+ end
28
+ def equal_with_array_mode?(other, array_mode: Kirei::Services::ArrayComparison::Mode::STRICT)
29
+ return false unless instance_of?(other.class)
30
+
31
+ instance_variables.all? do |var|
32
+ one = instance_variable_get(var)
33
+ two = other.instance_variable_get(var)
34
+ next one == two unless one.is_a?(Array)
35
+
36
+ Kirei::Services::ArrayComparison.call(one, two, mode: array_mode)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -38,6 +38,8 @@ module Kirei
38
38
  route = router.get(http_verb, req_path)
39
39
  return NOT_FOUND if route.nil?
40
40
 
41
+ router.current_env = env # expose the env to the controller
42
+
41
43
  params = case route.verb
42
44
  when Verb::GET
43
45
  query = T.cast(env.fetch("QUERY_STRING"), String)
@@ -25,6 +25,9 @@ module Kirei
25
25
  T::Hash[String, Route]
26
26
  end
27
27
 
28
+ sig { returns(T.nilable(T::Hash[String, T.untyped])) }
29
+ attr_accessor :current_env
30
+
28
31
  sig { void }
29
32
  def initialize
30
33
  @routes = T.let({}, RoutesHash)
@@ -0,0 +1,35 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Kirei
5
+ module Services
6
+ class ArrayComparison
7
+ extend T::Sig
8
+
9
+ class Mode < T::Enum
10
+ enums do
11
+ STRICT = new("strict")
12
+ IGNORE_ORDER = new("ignore_order")
13
+ IGNORE_ORDER_AND_DUPLICATES = new("ignore_order_and_duplicates")
14
+ end
15
+ end
16
+
17
+ sig do
18
+ params(
19
+ array_one: T::Array[T.untyped],
20
+ array_two: T::Array[T.untyped],
21
+ mode: Mode,
22
+ ).returns(T::Boolean)
23
+ end
24
+ def self.call(array_one, array_two, mode: Mode::STRICT)
25
+ case mode
26
+ when Mode::STRICT then array_one == array_two
27
+ when Mode::IGNORE_ORDER then array_one.sort == array_two.sort
28
+ when Mode::IGNORE_ORDER_AND_DUPLICATES then array_one.to_set == array_two.to_set
29
+ else
30
+ T.absurd(mode)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
data/lib/kirei/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Kirei
5
- VERSION = "0.6.3"
5
+ VERSION = "0.7.0"
6
6
  end
@@ -0,0 +1,16 @@
1
+ # typed: true
2
+
3
+ module Kirei
4
+ module Domain
5
+ module Entity
6
+ include Kernel
7
+
8
+ sig { returns(T.any(String, Integer)) }
9
+ def id; end
10
+ end
11
+
12
+ module ValueObject
13
+ include Kernel
14
+ end
15
+ end
16
+ end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kirei
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.3
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ludwig Reinmiedl
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-01-20 00:00:00.000000000 Z
11
+ date: 2025-03-18 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: logger
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.5'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.5'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: oj
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -139,7 +153,7 @@ dependencies:
139
153
  description: |
140
154
  Kirei is a Ruby micro/REST-framework for building scalable and performant microservices.
141
155
  It is built from the ground up to be clean and easy to use.
142
- It is a Rack app, and uses Sorbet for typing, Sequel as an ORM, Zeitwerk for autoloading, and Puma as a web server.
156
+ It is a Rack app, and uses Sorbet for typing, Sequel as an ORM, and Zeitwerk for autoloading.
143
157
  It strives to have zero magic and to be as explicit as possible.
144
158
  email:
145
159
  - lud@reinmiedl.com
@@ -169,6 +183,8 @@ files:
169
183
  - lib/kirei/app.rb
170
184
  - lib/kirei/config.rb
171
185
  - lib/kirei/controller.rb
186
+ - lib/kirei/domain/entity.rb
187
+ - lib/kirei/domain/value_object.rb
172
188
  - lib/kirei/errors/json_api_error.rb
173
189
  - lib/kirei/errors/json_api_error_source.rb
174
190
  - lib/kirei/helpers.rb
@@ -186,11 +202,13 @@ files:
186
202
  - lib/kirei/routing/route.rb
187
203
  - lib/kirei/routing/router.rb
188
204
  - lib/kirei/routing/verb.rb
205
+ - lib/kirei/services/array_comparison.rb
189
206
  - lib/kirei/services/result.rb
190
207
  - lib/kirei/services/runner.rb
191
208
  - lib/kirei/version.rb
192
209
  - lib/tasks/routes.rake
193
210
  - sorbet/rbi/shims/base_model.rbi
211
+ - sorbet/rbi/shims/domain.rbi
194
212
  - sorbet/rbi/shims/ruby.rbi
195
213
  homepage: https://github.com/swiknaba/kirei
196
214
  licenses: